From f23df2659d7ae355987a74270366c40831fac5cd Mon Sep 17 00:00:00 2001 From: Alexa <53471801+Alexa-RR@users.noreply.github.com> Date: Wed, 8 Jul 2026 01:29:54 +0300 Subject: [PATCH] feat: Update Images and Rooms APIs to support 2023 client URL structure and add tests for new functionality --- DorkNet.Server.Tests/RoomBrowse2023Tests.cs | 91 +++++++++++++++++++ .../API/Images/ImagesController.cs | 31 ++++++- .../API/Rooms/V2/RoomsController.cs | 60 ++++++++++-- 3 files changed, 168 insertions(+), 14 deletions(-) create mode 100644 DorkNet.Server.Tests/RoomBrowse2023Tests.cs diff --git a/DorkNet.Server.Tests/RoomBrowse2023Tests.cs b/DorkNet.Server.Tests/RoomBrowse2023Tests.cs new file mode 100644 index 0000000..73846a8 --- /dev/null +++ b/DorkNet.Server.Tests/RoomBrowse2023Tests.cs @@ -0,0 +1,91 @@ +using System.Net.Http.Headers; +using System.Text.Json; +using DorkNet.Server.Data; +using DorkNet.Server.Data.Entities; +using DorkNet.Server.Services; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace DorkNet.Server.Tests; + +/// +/// 2023 play-menu browse sources. The client's room-discovery URL +/// builder (IBEOONPEELF/GGDPFFDEDKN) requests +/// rooms/search_rooms/{query}&skip={skip}&take={take} — +/// note the paging pairs live INSIDE the final path segment, and the +/// whole family is prefixed with rooms/ on the rooms host. +/// The room photo gallery sends sort/filter as enum +/// names (CheerCount_Desc, PublicOnly), which must not +/// trip int model binding. +/// +public sealed class RoomBrowse2023Tests : IClassFixture +{ + private readonly DorkNetServerFactory _factory; + + public RoomBrowse2023Tests(DorkNetServerFactory factory) => _factory = factory; + + [Fact] + public async Task Search_rooms_2023_path_with_embedded_paging_returns_matches() + { + using var setupClient = Client(); + var owner = await GameClientSessionFactory.CreateAsync(setupClient, _factory.ApexDomain); + + var marker = $"Zz{Guid.NewGuid():N}"[..14]; + await using (var scope = _factory.Services.CreateAsyncScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + db.Rooms.Add(new RoomEntity + { + Id = 9_300_000 + Random.Shared.Next(1, 99_999), + Name = $"Search{marker}", + Description = "2023 search test room", + CreatorPlayerId = owner.PlayerId, + ImageName = RoomService.DefaultRoomImageName, + Accessibility = 1, + IsAGRoom = true, + IsDormRoom = false, + }); + await db.SaveChangesAsync(); + } + + using var roomsClient = Client(owner, subdomain: "rooms"); + using var response = await roomsClient.GetAsync($"/rooms/search_rooms/{marker}&skip=0&take=20"); + var text = await response.Content.ReadAsStringAsync(); + Assert.True(response.IsSuccessStatusCode, $"{(int)response.StatusCode}: {text}"); + + using var json = JsonDocument.Parse(text); + var names = json.RootElement.EnumerateArray() + .Select(e => e.GetProperty("Name").GetString() ?? string.Empty) + .ToArray(); + Assert.Contains(names, n => n.Contains(marker)); + } + + [Fact] + public async Task Room_images_accept_2023_enum_name_sort_and_filter() + { + using var setupClient = Client(); + var player = await GameClientSessionFactory.CreateAsync(setupClient, _factory.ApexDomain); + + using var apiClient = Client(player); + using var response = await apiClient.GetAsync( + "/api/images/v4/room/116?sort=CheerCount_Desc&filter=PublicOnly&take=8&skip=0"); + var text = await response.Content.ReadAsStringAsync(); + Assert.True(response.IsSuccessStatusCode, $"{(int)response.StatusCode}: {text}"); + + using var json = JsonDocument.Parse(text); + Assert.Equal(JsonValueKind.Array, json.RootElement.ValueKind); + } + + private HttpClient Client(GameClientSession? session = null, string subdomain = "api") + { + var client = _factory.CreateClient(new() { AllowAutoRedirect = false }); + client.BaseAddress = new Uri($"http://{subdomain}.{_factory.ApexDomain}"); + client.Timeout = TimeSpan.FromSeconds(30); + client.DefaultRequestHeaders.Accept.ParseAdd("application/json"); + client.DefaultRequestHeaders.UserAgent.ParseAdd("RecRoom/2023.03.21"); + client.DefaultRequestHeaders.TryAddWithoutValidation("X-DorkNet-Version", "march_2023_03_21"); + if (session is not null) + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", session.AccessToken); + return client; + } +} diff --git a/DorkNet.Server/Controllers/API/Images/ImagesController.cs b/DorkNet.Server/Controllers/API/Images/ImagesController.cs index 27d2dcc..ba0fb3f 100644 --- a/DorkNet.Server/Controllers/API/Images/ImagesController.cs +++ b/DorkNet.Server/Controllers/API/Images/ImagesController.cs @@ -401,14 +401,14 @@ public class ImagesController( [HttpGet("api/images/v4/player/{accountId:long}")] [HttpGet("api/images/v5/player/{accountId:long}")] public async Task GetByAccount(long accountId, - [FromQuery] int take = 50, [FromQuery] int skip = 0, [FromQuery] int sort = 0) + [FromQuery] int take = 50, [FromQuery] int skip = 0, [FromQuery] string? sort = null) { take = Math.Clamp(take, 1, 200); skip = Math.Max(0, skip); var query = db.Photos .Where(p => p.UploaderPlayerId == accountId && p.IsPublic && p.DeletedAt == null) .AsQueryable(); - query = sort switch + query = ParseSort(sort) switch { 1 => query.OrderByDescending(p => p.CheerCount).ThenByDescending(p => p.CreatedAt), 2 => query.OrderByDescending(p => p.ViewCount).ThenByDescending(p => p.CreatedAt), @@ -422,6 +422,13 @@ public class ImagesController( return Ok(rows.Select(p => BuildImageInfo(p, accountId))); } + /// GET api/images/v{2-5}/room/{roomId} — a room's public + /// photo gallery. The 2023 client sends sort/filter as + /// ENUM NAMES (?sort=CheerCount_Desc&filter=PublicOnly), + /// not ints — binding them as int makes [ApiController] + /// auto-400 and the watch toasts "Could not show images for room". + /// filter is accepted but unused: we only store public, + /// non-deleted photos for this view anyway (PublicOnly semantics). [HttpGet("api/images/v2/room/{roomId:long}")] [HttpGet("api/images/v3/room/{roomId:long}")] [HttpGet("api/images/v4/room/{roomId:long}")] @@ -430,16 +437,17 @@ public class ImagesController( long roomId, [FromQuery] int take = 50, [FromQuery] int skip = 0, - [FromQuery] int sort = 0, - [FromQuery] int filter = 0) + [FromQuery] string? sort = null, + [FromQuery] string? filter = null) { take = Math.Clamp(take, 1, 200); skip = Math.Max(0, skip); + _ = filter; var query = db.Photos .Where(p => p.RoomId == roomId && p.IsPublic && p.DeletedAt == null) .AsQueryable(); - query = sort switch + query = ParseSort(sort) switch { 1 => query.OrderByDescending(p => p.CheerCount).ThenByDescending(p => p.CreatedAt), 2 => query.OrderByDescending(p => p.ViewCount).ThenByDescending(p => p.CreatedAt), @@ -453,6 +461,19 @@ public class ImagesController( return Ok(rows.Select(p => BuildImageInfo(p, p.UploaderPlayerId))); } + /// Sort selector shared by the album views. Accepts the + /// 2020 client's ints (0/1/2) and the 2023 client's enum names — + /// the names contain the metric ("CheerCount_Desc", "ViewCount_…"); + /// anything unrecognised falls back to newest-first. + private static int ParseSort(string? sort) + { + if (string.IsNullOrWhiteSpace(sort)) return 0; + if (int.TryParse(sort, out var n)) return n; + if (sort.Contains("Cheer", StringComparison.OrdinalIgnoreCase)) return 1; + if (sort.Contains("View", StringComparison.OrdinalIgnoreCase)) return 2; + return 0; + } + /// POST api/images/v{2-5}/share/{id} — flip a saved photo /// to public so it shows up on feed.rec.net. The watch's "Share" /// button hits this; if the watch posts to a different URL we'll diff --git a/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs b/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs index a392282..2ece508 100644 --- a/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs +++ b/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs @@ -2758,18 +2758,21 @@ public class RoomsController( /// [HttpGet("roomserver/hot_roomsandplaylists/{tagsJoined?}")] [HttpGet("hot_roomsandplaylists/{tagsJoined?}")] + [HttpGet("rooms/hot_roomsandplaylists/{tagsJoined?}")] public async Task HotRoomsAndPlaylistsPath(string? tagsJoined) { // Watch joins multiple tags with "," — use the first one as a // substring filter since RoomService.HotAsync takes a single - // tag arg. Empty / null = unfiltered. + // tag arg. Empty / null = unfiltered. The 2023 client embeds + // &skip/&take in the same segment (see ParsePagedPathSegment). // // Returns rooms-only for the same reason as the wrapped // RoomsAndPlaylistsHot endpoint above: playlists belong in // Moods, not in the Hot tab. See that endpoint's doc-comment. - var firstTag = string.IsNullOrWhiteSpace(tagsJoined) + var (tagsValue, _, _) = ParsePagedPathSegment(tagsJoined, defaultTake: 100); + var firstTag = string.IsNullOrWhiteSpace(tagsValue) ? null - : tagsJoined.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + : tagsValue.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .FirstOrDefault(); var (entries, _) = await BuildHotRoomsAsync(firstTag); return Ok(entries); @@ -2782,11 +2785,14 @@ public class RoomsController( /// rows so the watch's rooms-only browse tab doesn't render /// playlist tiles that have nowhere to go. [HttpGet("hot_rooms/{tagsJoined?}")] + [HttpGet("rooms/hot_rooms/{tagsJoined?}")] + [HttpGet("roomserver/hot_rooms/{tagsJoined?}")] public async Task HotRoomsOnlyPath(string? tagsJoined) { - var firstTag = string.IsNullOrWhiteSpace(tagsJoined) + var (tagsValue, _, _) = ParsePagedPathSegment(tagsJoined, defaultTake: 12); + var firstTag = string.IsNullOrWhiteSpace(tagsValue) ? null - : tagsJoined.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + : tagsValue.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .FirstOrDefault(); var roomList = await rooms.HotAsync(firstTag, take: 12); var roomIds = roomList.Select(r => r.Id).ToList(); @@ -2983,22 +2989,35 @@ public class RoomsController( /// [HttpGet("roomserver/search_roomsandplaylists/{query}")] [HttpGet("search_roomsandplaylists/{query}")] + [HttpGet("rooms/search_roomsandplaylists/{query}")] public async Task SearchRoomsAndPlaylists(string query) { - var (merged, _) = await BuildSearchUnionAsync(query); + var (text, _, _) = ParsePagedPathSegment(query, defaultTake: 50); + var (merged, _) = await BuildSearchUnionAsync(text); return Ok(merged); } /// GET search_rooms/{query} — rooms-only search /// variant. Same MKAMHOIHOJK union entry shape as /// search_roomsandplaylists/{query}, just skips playlist - /// rows. + /// rows. + /// + /// The 2023 client's play-menu search (IBEOONPEELF.SearchRooms, + /// URL builder GGDPFFDEDKN) calls the rooms/-prefixed + /// variant and embeds paging in the SAME path segment: + /// rooms/search_rooms/{query}&skip={skip}&take={take}. + /// Missing the prefixed route = "Error refreshing rooms from source + /// ServerSearch" in the play menu. [HttpGet("search_rooms/{query}")] + [HttpGet("rooms/search_rooms/{query}")] + [HttpGet("roomserver/search_rooms/{query}")] public async Task SearchRoomsOnly(string query) { - if (string.IsNullOrWhiteSpace(query)) + var (text, skip, take) = ParsePagedPathSegment(query, defaultTake: 50); + if (string.IsNullOrWhiteSpace(text)) return Ok(Array.Empty()); - var roomList = await rooms.SearchAsync(query); + var roomList = (await rooms.SearchAsync(text, take: skip + take)) + .Skip(skip).Take(take).ToList(); var roomIds = roomList.Select(r => r.Id).ToList(); var sceneRows = roomIds.Count == 0 ? new List() @@ -3011,6 +3030,29 @@ public class RoomsController( .ToList()); } + /// Split a 2023-style browse-source path segment into its + /// value and paging. The 2023 URL builder (GGDPFFDEDKN) + /// formats search_rooms/{0}&skip={1}&take={2} / + /// hot_rooms/{0}&skip={1}&take={2} — the paging pairs + /// land inside the final path segment, not the query string, so + /// route params capture "pizza&skip=0&take=20" verbatim. + /// 2020 URLs have no '&' and pass through unchanged. + private static (string Value, int Skip, int Take) ParsePagedPathSegment(string? raw, int defaultTake) + { + if (string.IsNullOrEmpty(raw)) return (string.Empty, 0, defaultTake); + var parts = raw.Split('&'); + var skip = 0; + var take = defaultTake; + foreach (var pair in parts.Skip(1)) + { + var kv = pair.Split('=', 2); + if (kv.Length != 2 || !int.TryParse(kv[1], out var n)) continue; + if (kv[0].Equals("skip", StringComparison.OrdinalIgnoreCase)) skip = n; + else if (kv[0].Equals("take", StringComparison.OrdinalIgnoreCase)) take = n; + } + return (parts[0], Math.Max(0, skip), Math.Clamp(take, 1, 100)); + } + /// /// Shared body for the two search variants. Empty/whitespace query /// returns an empty result without round-tripping the DB.