mirror of
https://github.com/JogmansonReal/Radicade.git
synced 2026-10-25 15:09:26 -07:00
131 lines
5.1 KiB
C#
131 lines
5.1 KiB
C#
using System;
|
|
using System.IO;
|
|
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using RadicadeServer.CDN;
|
|
using RadicadeServer.DB;
|
|
using Xunit;
|
|
|
|
namespace RadicadeServer.Tests;
|
|
|
|
/// <summary>
|
|
/// The client's Unity asset bundle download is resumable: its string table carries
|
|
/// "bytes={0}-", "bytes={0}-{1}", "Accept-Ranges" and "Content-Range", so it asks the CDN for
|
|
/// byte ranges rather than a whole file. Every route that serves those bytes must therefore
|
|
/// process ranges — a range answer that returns the entire body makes the client re-transfer
|
|
/// the whole file once per range, which is how a 73 MB Studio bundle came to spend 228 s in
|
|
/// AssetBundle_Download, outlive the room's Photon connection, and fail the load with
|
|
/// code 6706-3. Dropping <c>enableRangeProcessing</c> is invisible at runtime (the download
|
|
/// still completes, just ~20x slower and past the network timeout), so it is pinned here.
|
|
/// </summary>
|
|
[Collection(DbFactoryCollection.Name)]
|
|
public class CdnRangeRequestTests : IDisposable
|
|
{
|
|
private const string BlobName = "recdump-0cfd85d85c7b905dfbb83a6f3d3c887b.room";
|
|
|
|
private readonly string root = Directory.CreateTempSubdirectory("radicade-cdn-range-test-").FullName;
|
|
private readonly string? previousStorage;
|
|
|
|
public CdnRangeRequestTests()
|
|
{
|
|
previousStorage = DBConfig.CDNStorage;
|
|
DBConfig.CDNStorage = root;
|
|
File.WriteAllText(Path.Join(root, BlobName), "blob");
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
DBConfig.CDNStorage = previousStorage!;
|
|
|
|
try
|
|
{
|
|
Directory.Delete(root, true);
|
|
}
|
|
catch (IOException)
|
|
{
|
|
}
|
|
}
|
|
|
|
private static readonly string ImmutableCache = "public, max-age=31536000, immutable";
|
|
private static readonly string ReplaceableCache = "public, max-age=86400";
|
|
|
|
private const string BundleName = "0f0f0f0f-0f0f-0f0f-0f0f-0f0f0f0f0f0f.assetbundle";
|
|
|
|
private static T WithHttpContext<T>(T controller) where T : Controller
|
|
{
|
|
controller.ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() };
|
|
return controller;
|
|
}
|
|
|
|
private static PhysicalFileResult RangeServed(IActionResult result)
|
|
{
|
|
var file = Assert.IsType<PhysicalFileResult>(result);
|
|
Assert.True(file.EnableRangeProcessing, $"{file.FileName} is served without range processing");
|
|
Assert.Equal("application/octet-stream", file.ContentType);
|
|
return file;
|
|
}
|
|
|
|
[Fact]
|
|
public void TheDataRouteThatCarriesAssetBundlesProcessesRanges()
|
|
{
|
|
// /data/{blob} is where the bundle bytes land: /rooms/unity_assets/... hands the
|
|
// client a Filename and nothing else, so this is the URL the downloader ranges over.
|
|
var file = RangeServed(new DataCDN().GetDataBlob(BlobName));
|
|
|
|
Assert.Equal(Path.Join(root, BlobName), file.FileName);
|
|
}
|
|
|
|
[Fact]
|
|
public void TheRoomAndUnityAssetRoutesProcessRanges()
|
|
{
|
|
RangeServed(new RoomsCDN().GetDataBlob(BlobName));
|
|
RangeServed(new RoomsCDN().GetUnityAssetRaw(BlobName, 0, 1));
|
|
RangeServed(new RoomsCDN().GetUnityAssetRaw(BlobName, null, null));
|
|
}
|
|
|
|
[Fact]
|
|
public void TheInventionRouteProcessesRanges()
|
|
{
|
|
RangeServed(new InventionCDN().GetDataBlob(BlobName));
|
|
}
|
|
|
|
[Fact]
|
|
public void TheBlobRoutesTellClientsTheyMayCacheWhatTheyDownloaded()
|
|
{
|
|
// A Studio room's bundle is the payload that decides whether the room loads at all.
|
|
// With no cache directive the client's HTTP stack is free to fetch all of it again
|
|
// on the next join, at ~100 ms RTT, and the room load is exactly as slow as it was
|
|
// the first time.
|
|
File.WriteAllText(Path.Join(root, BundleName), "bundle");
|
|
|
|
var data = WithHttpContext(new DataCDN());
|
|
data.GetDataBlob(BundleName);
|
|
Assert.Equal(ImmutableCache, data.Response.Headers["Cache-Control"].ToString());
|
|
|
|
var rooms = WithHttpContext(new RoomsCDN());
|
|
rooms.GetUnityAssetRaw(BundleName, 0, 1);
|
|
Assert.Equal(ImmutableCache, rooms.Response.Headers["Cache-Control"].ToString());
|
|
|
|
// Room saves are content-addressed too, but admin repairs replace one under its
|
|
// existing name, so they must expire rather than be pinned forever.
|
|
data = WithHttpContext(new DataCDN());
|
|
data.GetDataBlob(BlobName);
|
|
Assert.Equal(ReplaceableCache, data.Response.Headers["Cache-Control"].ToString());
|
|
|
|
rooms = WithHttpContext(new RoomsCDN());
|
|
rooms.GetDataBlob(BlobName);
|
|
Assert.Equal(ReplaceableCache, rooms.Response.Headers["Cache-Control"].ToString());
|
|
}
|
|
|
|
[Fact]
|
|
public void AnUnknownBlobStillAnswersNotFound()
|
|
{
|
|
// Range support must not turn a missing file into a body, the way an unhandled
|
|
// unsatisfiable range can.
|
|
Assert.IsType<NotFoundResult>(new DataCDN().GetDataBlob("not-on-disk.room"));
|
|
Assert.IsType<NotFoundResult>(new RoomsCDN().GetDataBlob("not-on-disk.room"));
|
|
Assert.IsType<NotFoundResult>(new RoomsCDN().GetUnityAssetRaw("not-on-disk.room", 0, 1));
|
|
Assert.IsType<NotFoundResult>(new InventionCDN().GetDataBlob("not-on-disk.room"));
|
|
}
|
|
}
|