mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-25 15:08:47 -07:00
feat: Update Images and Rooms APIs to support 2023 client URL structure and add tests for new functionality
This commit is contained in:
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 2023 play-menu browse sources. The client's room-discovery URL
|
||||
/// builder (IBEOONPEELF/GGDPFFDEDKN) requests
|
||||
/// <c>rooms/search_rooms/{query}&skip={skip}&take={take}</c> —
|
||||
/// note the paging pairs live INSIDE the final path segment, and the
|
||||
/// whole family is prefixed with <c>rooms/</c> on the rooms host.
|
||||
/// The room photo gallery sends <c>sort</c>/<c>filter</c> as enum
|
||||
/// names (<c>CheerCount_Desc</c>, <c>PublicOnly</c>), which must not
|
||||
/// trip int model binding.
|
||||
/// </summary>
|
||||
public sealed class RoomBrowse2023Tests : IClassFixture<DorkNetServerFactory>
|
||||
{
|
||||
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<DorkNetDbContext>();
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -401,14 +401,14 @@ public class ImagesController(
|
||||
[HttpGet("api/images/v4/player/{accountId:long}")]
|
||||
[HttpGet("api/images/v5/player/{accountId:long}")]
|
||||
public async Task<IActionResult> 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)));
|
||||
}
|
||||
|
||||
/// <summary>GET api/images/v{2-5}/room/{roomId} — a room's public
|
||||
/// photo gallery. The 2023 client sends <c>sort</c>/<c>filter</c> as
|
||||
/// ENUM NAMES (<c>?sort=CheerCount_Desc&filter=PublicOnly</c>),
|
||||
/// not ints — binding them as <c>int</c> makes [ApiController]
|
||||
/// auto-400 and the watch toasts "Could not show images for room".
|
||||
/// <c>filter</c> is accepted but unused: we only store public,
|
||||
/// non-deleted photos for this view anyway (PublicOnly semantics).</summary>
|
||||
[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)));
|
||||
}
|
||||
|
||||
/// <summary>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.</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>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
|
||||
|
||||
@@ -2758,18 +2758,21 @@ public class RoomsController(
|
||||
/// </summary>
|
||||
[HttpGet("roomserver/hot_roomsandplaylists/{tagsJoined?}")]
|
||||
[HttpGet("hot_roomsandplaylists/{tagsJoined?}")]
|
||||
[HttpGet("rooms/hot_roomsandplaylists/{tagsJoined?}")]
|
||||
public async Task<IActionResult> 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.</summary>
|
||||
[HttpGet("hot_rooms/{tagsJoined?}")]
|
||||
[HttpGet("rooms/hot_rooms/{tagsJoined?}")]
|
||||
[HttpGet("roomserver/hot_rooms/{tagsJoined?}")]
|
||||
public async Task<IActionResult> 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(
|
||||
/// </summary>
|
||||
[HttpGet("roomserver/search_roomsandplaylists/{query}")]
|
||||
[HttpGet("search_roomsandplaylists/{query}")]
|
||||
[HttpGet("rooms/search_roomsandplaylists/{query}")]
|
||||
public async Task<IActionResult> SearchRoomsAndPlaylists(string query)
|
||||
{
|
||||
var (merged, _) = await BuildSearchUnionAsync(query);
|
||||
var (text, _, _) = ParsePagedPathSegment(query, defaultTake: 50);
|
||||
var (merged, _) = await BuildSearchUnionAsync(text);
|
||||
return Ok(merged);
|
||||
}
|
||||
|
||||
/// <summary>GET <c>search_rooms/{query}</c> — rooms-only search
|
||||
/// variant. Same <c>MKAMHOIHOJK</c> union entry shape as
|
||||
/// <c>search_roomsandplaylists/{query}</c>, just skips playlist
|
||||
/// rows.</summary>
|
||||
/// rows.
|
||||
///
|
||||
/// The 2023 client's play-menu search (<c>IBEOONPEELF.SearchRooms</c>,
|
||||
/// URL builder <c>GGDPFFDEDKN</c>) calls the <c>rooms/</c>-prefixed
|
||||
/// variant and embeds paging in the SAME path segment:
|
||||
/// <c>rooms/search_rooms/{query}&skip={skip}&take={take}</c>.
|
||||
/// Missing the prefixed route = "Error refreshing rooms from source
|
||||
/// ServerSearch" in the play menu.</summary>
|
||||
[HttpGet("search_rooms/{query}")]
|
||||
[HttpGet("rooms/search_rooms/{query}")]
|
||||
[HttpGet("roomserver/search_rooms/{query}")]
|
||||
public async Task<IActionResult> SearchRoomsOnly(string query)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(query))
|
||||
var (text, skip, take) = ParsePagedPathSegment(query, defaultTake: 50);
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
return Ok(Array.Empty<object>());
|
||||
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<RoomSceneEntity>()
|
||||
@@ -3011,6 +3030,29 @@ public class RoomsController(
|
||||
.ToList());
|
||||
}
|
||||
|
||||
/// <summary>Split a 2023-style browse-source path segment into its
|
||||
/// value and paging. The 2023 URL builder (<c>GGDPFFDEDKN</c>)
|
||||
/// formats <c>search_rooms/{0}&skip={1}&take={2}</c> /
|
||||
/// <c>hot_rooms/{0}&skip={1}&take={2}</c> — 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.</summary>
|
||||
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));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shared body for the two search variants. Empty/whitespace query
|
||||
/// returns an empty result without round-tripping the DB.
|
||||
|
||||
Reference in New Issue
Block a user