Files
allstarr/allstarr.Tests/Protocols/ProtocolSupportMatrixTests.cs
T

548 lines
28 KiB
C#

using System.Security.Cryptography;
using System.Text.Json;
using System.Text.RegularExpressions;
using allstarr.Services.Jellyfin;
using Microsoft.AspNetCore.Http;
namespace allstarr.Tests;
public sealed class ProtocolSupportMatrixTests
{
[Fact]
public void SourceLock_MatchesPinnedJellyfinOpenApi()
{
using var sourceLock = ReadFixture("protocol-source-lock.json");
AssertPinnedOpenApi(
sourceLock.RootElement.GetProperty("jellyfinOpenApi"),
"https://fra1.mirror.jellyfin.org/files/files/openapi/jellyfin-openapi-stable.json",
expectedServerCommit: null);
AssertPinnedOpenApi(
sourceLock.RootElement.GetProperty("jellyfinOpenApi10"),
"https://blr1.mirror.jellyfin.org/files/files/files/openapi/stable/jellyfin-openapi-10.11.11.json",
"1fbd8739292cce610231be93daf43368733edf63");
AssertPinnedSource(
sourceLock.RootElement.GetProperty("octoFiesta"),
"https://github.com/V1ck3s/octo-fiesta");
AssertPinnedSource(
sourceLock.RootElement.GetProperty("jellyfinLastFmReference"),
"https://github.com/danielfariati/jellyfin-plugin-lastfm");
}
private static void AssertPinnedOpenApi(
JsonElement expected,
string expectedUrl,
string? expectedServerCommit)
{
Assert.Equal(expectedUrl, expected.GetProperty("sourceUrl").GetString());
var repositoryRoot = FindRepositoryRoot();
var openApiPath = Path.Combine(repositoryRoot, expected.GetProperty("path").GetString()!);
using var openApi = JsonDocument.Parse(File.ReadAllText(openApiPath));
Assert.Equal(expected.GetProperty("version").GetString(),
openApi.RootElement.GetProperty("info").GetProperty("version").GetString());
using var stream = File.OpenRead(openApiPath);
var actualHash = Convert.ToHexString(SHA256.HashData(stream)).ToLowerInvariant();
Assert.Equal(expected.GetProperty("sha256").GetString(), actualHash);
if (expectedServerCommit != null)
{
Assert.Equal(expectedServerCommit, expected.GetProperty("serverCommit").GetString());
}
}
private static void AssertPinnedSource(JsonElement source, string expectedUrl)
{
Assert.Equal(expectedUrl, source.GetProperty("sourceUrl").GetString());
var commit = source.GetProperty("commit").GetString();
Assert.NotNull(commit);
Assert.Matches("^[0-9a-f]{40}$", commit);
}
[Fact]
public void JellyfinOpenApi_ContainsEveryPinnedInstantMixPath()
{
var openApiPath = Path.Combine(FindRepositoryRoot(), "apis", "specifications", "jellyfin", "openapi-12.0.0.json");
using var openApi = JsonDocument.Parse(File.ReadAllText(openApiPath));
var paths = openApi.RootElement.GetProperty("paths");
var required = new[]
{
"/Albums/{itemId}/InstantMix",
"/Artists/{itemId}/InstantMix",
"/Items/{itemId}/InstantMix",
"/MusicGenres/InstantMix",
"/MusicGenres/{name}/InstantMix",
"/Playlists/{itemId}/InstantMix",
"/Songs/{itemId}/InstantMix"
};
Assert.All(required, path => Assert.True(paths.TryGetProperty(path, out _), path));
}
[Fact]
public void JellyfinOpenApi_EveryOperationMatchesReviewedMusicOnlyPolicy()
{
using var qualification = ReadFixture("jellyfin-openapi-qualification.json");
var contract = qualification.RootElement;
var openApiContract = contract.GetProperty("openApi");
Assert.Equal("Denied", openApiContract.GetProperty("defaultAccess").GetString());
var openApiPath = Path.Combine(
FindRepositoryRoot(),
"apis",
"specifications",
"jellyfin",
"openapi-12.0.0.json");
using var openApi = JsonDocument.Parse(File.ReadAllText(openApiPath));
Assert.Equal(
openApiContract.GetProperty("version").GetString(),
openApi.RootElement.GetProperty("info").GetProperty("version").GetString());
var reviewed = new Dictionary<string, JellyfinEndpointAccess>(StringComparer.Ordinal);
foreach (var accessGroup in contract.GetProperty("allowedOperations").EnumerateObject())
{
var access = Enum.Parse<JellyfinEndpointAccess>(accessGroup.Name);
foreach (var operationId in accessGroup.Value.EnumerateArray().Select(value => value.GetString()!))
Assert.True(reviewed.TryAdd(operationId, access), $"Duplicate reviewed operation {operationId}");
}
var synthesizedDenied = contract.GetProperty("synthesizedDeniedOperations")
.EnumerateArray()
.Select(value => value.GetString()!)
.ToHashSet(StringComparer.Ordinal);
Assert.All(synthesizedDenied, operationId =>
Assert.Equal(JellyfinEndpointAccess.RequiresMusicItem, reviewed[operationId]));
var operations = new List<(string Id, string Method, string Path)>();
var methods = new HashSet<string>(
["GET", "PUT", "POST", "DELETE", "PATCH", "OPTIONS", "HEAD", "TRACE"],
StringComparer.Ordinal);
foreach (var path in openApi.RootElement.GetProperty("paths").EnumerateObject())
{
foreach (var method in path.Value.EnumerateObject())
{
if (!methods.Contains(method.Name.ToUpperInvariant())) continue;
operations.Add((
method.Value.GetProperty("operationId").GetString()!,
method.Name.ToUpperInvariant(),
path.Name));
}
}
Assert.Equal(openApiContract.GetProperty("operationCount").GetInt32(), operations.Count);
Assert.Equal(operations.Count, operations.Select(operation => operation.Id).Distinct().Count());
Assert.All(reviewed.Keys, operationId =>
Assert.Contains(operations, operation => operation.Id == operationId));
foreach (var operation in operations)
{
var context = new DefaultHttpContext();
context.Request.Method = operation.Method;
context.Request.Path = Regex.Replace(operation.Path, "\\{[^}]+\\}", "fixture-id");
var actual = JellyfinMusicEndpointPolicy.Evaluate(context.Request).Access;
var expected = reviewed.GetValueOrDefault(operation.Id, JellyfinEndpointAccess.Denied);
Assert.True(
expected == actual,
$"{operation.Method} {operation.Path} ({operation.Id}) expected {expected}, actual {actual}");
if (expected == JellyfinEndpointAccess.RequiresPlaylistItem)
{
Assert.Contains(operation.Id, new[] { "DeleteItem", "UpdateItem" });
continue;
}
if (expected != JellyfinEndpointAccess.RequiresMusicItem) continue;
const string synthesizedId = "ext-fixture-song-1";
var synthesizedContext = new DefaultHttpContext();
synthesizedContext.Request.Method = operation.Method;
synthesizedContext.Request.Path = SynthesizedOperationPath(operation.Path, synthesizedId);
var supportsSynthesized = JellyfinMusicEndpointPolicy.SupportsSynthesizedItemRoute(
synthesizedContext.Request,
synthesizedId);
Assert.True(
supportsSynthesized != synthesizedDenied.Contains(operation.Id),
$"{operation.Method} {operation.Path} ({operation.Id}) has an unreviewed synthesized-resource mode");
}
var typedModes = contract.GetProperty("synthesizedTypedOperations");
var supportedTyped = typedModes.GetProperty("supported").EnumerateArray().ToList();
var deniedTyped = typedModes.GetProperty("denied").EnumerateArray().ToList();
var reviewedTypedIds = supportedTyped.Concat(deniedTyped)
.Select(mode => mode.GetProperty("operationId").GetString()!)
.ToArray();
var expectedTypedIds = operations.Where(operation =>
operation.Path.Contains('{') &&
(operation.Path.StartsWith("/Artists/", StringComparison.Ordinal) ||
operation.Path.StartsWith("/Albums/", StringComparison.Ordinal) ||
operation.Path.StartsWith("/Songs/", StringComparison.Ordinal) ||
operation.Path.StartsWith("/Genres/", StringComparison.Ordinal) ||
operation.Path.StartsWith("/MusicGenres/", StringComparison.Ordinal)))
.Select(operation => operation.Id)
.Append("GetInstantMixFromMusicGenreById")
.Order()
.ToArray();
Assert.Equal(expectedTypedIds, reviewedTypedIds.Order().ToArray());
Assert.Equal(reviewedTypedIds.Length, reviewedTypedIds.Distinct(StringComparer.Ordinal).Count());
foreach (var mode in supportedTyped.Concat(deniedTyped))
{
Assert.Contains(
mode.GetProperty("resourceType").GetString(),
new[] { "song", "album", "artist", "genre" });
var uri = new Uri("http://localhost" + mode.GetProperty("path").GetString());
var context = new DefaultHttpContext();
context.Request.Method = mode.GetProperty("method").GetString()!;
context.Request.Path = uri.AbsolutePath;
context.Request.QueryString = new QueryString(uri.Query);
var controllerEnforced = mode.TryGetProperty("enforcement", out var enforcement) &&
enforcement.GetString() == "controller";
var expected = supportedTyped.Any(candidate =>
candidate.GetProperty("operationId").GetString() ==
mode.GetProperty("operationId").GetString()) ||
controllerEnforced
? JellyfinEndpointAccess.Music
: JellyfinEndpointAccess.Denied;
Assert.Equal(expected, JellyfinMusicEndpointPolicy.Evaluate(context.Request).Access);
}
var synthesizedPlaylistModes = contract.GetProperty("synthesizedPlaylistOperations");
var projectedPlaylistOperations = synthesizedPlaylistModes.GetProperty("projectedReads")
.EnumerateArray().Select(value => value.GetString()!).ToHashSet(StringComparer.Ordinal);
var targetRequiredPlaylistOperations = synthesizedPlaylistModes.GetProperty("targetRequired")
.EnumerateArray().Select(value => value.GetString()!).ToHashSet(StringComparer.Ordinal);
var notApplicablePlaylistOperations = synthesizedPlaylistModes.GetProperty("notApplicable")
.EnumerateArray().Select(value => value.GetString()!).ToHashSet(StringComparer.Ordinal);
var supportedPlaylistOperations = projectedPlaylistOperations
.Concat(targetRequiredPlaylistOperations)
.ToHashSet(StringComparer.Ordinal);
var reviewedPlaylistOperations = supportedPlaylistOperations
.Concat(notApplicablePlaylistOperations)
.ToList();
Assert.Equal(reviewedPlaylistOperations.Count,
reviewedPlaylistOperations.Distinct(StringComparer.Ordinal).Count());
Assert.Equal(
operations.Where(operation => operation.Path.StartsWith("/Playlists", StringComparison.Ordinal))
.Select(operation => operation.Id).Order(),
reviewedPlaylistOperations.Order());
const string synthesizedPlaylistId = "allstarr-vpl-0198a537719c7ea89e5a17e1f2f963f0";
foreach (var operationId in supportedPlaylistOperations)
{
var operation = Assert.Single(operations, candidate => candidate.Id == operationId);
var context = new DefaultHttpContext();
context.Request.Method = operation.Method;
context.Request.Path = Regex.Replace(
operation.Path
.Replace("{playlistId}", synthesizedPlaylistId, StringComparison.OrdinalIgnoreCase)
.Replace("{itemId}", synthesizedPlaylistId, StringComparison.OrdinalIgnoreCase),
"\\{[^}]+\\}",
"fixture-id");
Assert.True(JellyfinMusicEndpointPolicy.SupportsSynthesizedPlaylistRoute(
context.Request, synthesizedPlaylistId));
Assert.Equal(
JellyfinEndpointAccess.Music,
JellyfinMusicEndpointPolicy.Evaluate(context.Request).Access);
}
}
[Fact]
public void JellyfinOpenApi_10_11EveryOperationMatchesReviewedMusicOnlyPolicy()
{
using var baseline = ReadFixture("jellyfin-openapi-qualification.json");
using var qualification = ReadFixture("jellyfin-openapi-10.11-qualification.json");
var reviewed = new Dictionary<string, JellyfinEndpointAccess>(StringComparer.Ordinal);
foreach (var accessGroup in baseline.RootElement.GetProperty("allowedOperations").EnumerateObject())
{
var access = Enum.Parse<JellyfinEndpointAccess>(accessGroup.Name);
foreach (var operationId in accessGroup.Value.EnumerateArray().Select(value => value.GetString()!))
Assert.True(reviewed.TryAdd(operationId, access), operationId);
}
var versionOnly = qualification.RootElement.GetProperty("versionOnlyOperations");
foreach (var accessGroup in versionOnly.GetProperty("allowed").EnumerateObject())
{
var access = Enum.Parse<JellyfinEndpointAccess>(accessGroup.Name);
foreach (var operationId in accessGroup.Value.EnumerateArray().Select(value => value.GetString()!))
Assert.True(reviewed.TryAdd(operationId, access), operationId);
}
var root = FindRepositoryRoot();
using var openApi10 = JsonDocument.Parse(File.ReadAllText(Path.Combine(
root, "apis", "specifications", "jellyfin", "openapi-10.11.11.json")));
using var openApi12 = JsonDocument.Parse(File.ReadAllText(Path.Combine(
root, "apis", "specifications", "jellyfin", "openapi-12.0.0.json")));
var operations10 = ReadOperations(openApi10.RootElement);
var operations12 = ReadOperations(openApi12.RootElement);
var contract = qualification.RootElement.GetProperty("openApi");
Assert.Equal(contract.GetProperty("version").GetString(),
openApi10.RootElement.GetProperty("info").GetProperty("version").GetString());
Assert.Equal(contract.GetProperty("operationCount").GetInt32(), operations10.Count);
Assert.Equal(operations10.Count, operations10.Select(operation => operation.Id).Distinct().Count());
var onlyIn10 = operations10.Select(operation => operation.Id)
.Except(operations12.Select(operation => operation.Id), StringComparer.Ordinal)
.Order().ToArray();
var reviewedOnlyIn10 = versionOnly.GetProperty("allowed").EnumerateObject()
.SelectMany(group => group.Value.EnumerateArray().Select(value => value.GetString()!))
.Concat(versionOnly.GetProperty("denied").EnumerateArray().Select(value => value.GetString()!))
.Order().ToArray();
Assert.Equal(reviewedOnlyIn10, onlyIn10);
var onlyIn12 = operations12.Select(operation => operation.Id)
.Except(operations10.Select(operation => operation.Id), StringComparer.Ordinal)
.Order().ToArray();
Assert.Equal(
qualification.RootElement.GetProperty("onlyInTwelve")
.EnumerateArray().Select(value => value.GetString()!).Order().ToArray(),
onlyIn12);
foreach (var operation in operations10)
{
var context = new DefaultHttpContext();
context.Request.Method = operation.Method;
context.Request.Path = Regex.Replace(operation.Path, "\\{[^}]+\\}", "fixture-id");
var expected = reviewed.GetValueOrDefault(operation.Id, JellyfinEndpointAccess.Denied);
var actual = JellyfinMusicEndpointPolicy.Evaluate(context.Request).Access;
Assert.True(
expected == actual,
$"{operation.Method} {operation.Path} ({operation.Id}) expected {expected}, actual {actual}");
if (!onlyIn10.Contains(operation.Id, StringComparer.Ordinal) ||
expected != JellyfinEndpointAccess.RequiresMusicItem)
continue;
const string synthesizedId = "ext-fixture-song-1";
var synthesizedContext = new DefaultHttpContext();
synthesizedContext.Request.Method = operation.Method;
synthesizedContext.Request.Path = SynthesizedOperationPath(operation.Path, synthesizedId);
Assert.False(JellyfinMusicEndpointPolicy.SupportsSynthesizedItemRoute(
synthesizedContext.Request, synthesizedId));
}
Assert.Equal(
operations12.Where(operation => operation.Path.StartsWith("/Playlists", StringComparison.Ordinal))
.Select(operation => operation.Id).Order(),
operations10.Where(operation => operation.Path.StartsWith("/Playlists", StringComparison.Ordinal))
.Select(operation => operation.Id).Order());
var typed10 = Assert.Single(
qualification.RootElement.GetProperty("synthesizedTypedOperations")
.GetProperty("supported").EnumerateArray());
Assert.Equal("GetInstantMixFromArtists2", typed10.GetProperty("operationId").GetString());
var typed10Uri = new Uri("http://localhost" + typed10.GetProperty("path").GetString());
var typed10Context = new DefaultHttpContext();
typed10Context.Request.Method = typed10.GetProperty("method").GetString()!;
typed10Context.Request.Path = typed10Uri.AbsolutePath;
typed10Context.Request.QueryString = new QueryString(typed10Uri.Query);
Assert.Equal(
JellyfinEndpointAccess.Music,
JellyfinMusicEndpointPolicy.Evaluate(typed10Context.Request).Access);
}
[Fact]
public void JellyfinOpenApi_QualificationRecordsComparisonsDtoRulesAndLiveBlockers()
{
using var qualification = ReadFixture("jellyfin-openapi-qualification.json");
var root = qualification.RootElement;
Assert.Equal("blocked-no-runtime",
root.GetProperty("liveEvidence").GetProperty("runtime12Status").GetString());
Assert.All(
new[] { "native-relay", "constrained-relay", "synthesized", "transformed", "binary-relay", "denied" },
name => Assert.True(root.GetProperty("comparisonClasses").TryGetProperty(name, out _), name));
Assert.NotEmpty(root.GetProperty("clientDtoRequirements").EnumerateObject());
Assert.Equal(
["ItemIds", "Shares"],
root.GetProperty("clientDtoRequirements").GetProperty("playlistDefinitionRequired")
.EnumerateArray().Select(value => value.GetString()!).ToArray());
Assert.Equal(
["Key"],
root.GetProperty("clientDtoRequirements").GetProperty("userDataRequired")
.EnumerateArray().Select(value => value.GetString()!).ToArray());
var differences = root.GetProperty("intentionalDifferences").EnumerateArray().ToArray();
Assert.NotEmpty(differences);
var publicInfo = Assert.Single(differences, difference =>
difference.GetProperty("area").GetString() == "public server info");
Assert.Equal("transformed", publicInfo.GetProperty("class").GetString());
Assert.Equal(
"Only LocalAddress changes so clients reconnect through Allstarr. " +
"Server identity, product, version, operating system, capabilities, and unknown fields remain Jellyfin-owned.",
publicInfo.GetProperty("difference").GetString());
Assert.Contains(root.GetProperty("liveModes").EnumerateArray(),
mode => mode.GetProperty("status").GetString() == "blocked-without-explicit-opt-in");
}
[Fact]
public void SupportMatrix_HasUniqueCompleteRowsForBothProtocols()
{
using var matrix = ReadFixture("protocol-support-matrix.json");
var rows = matrix.RootElement.EnumerateArray().ToList();
Assert.Contains(rows, row => row.GetProperty("protocol").GetString() == "jellyfin");
Assert.Contains(rows, row => row.GetProperty("protocol").GetString() == "subsonic");
Assert.Equal(
rows.Count,
rows.Select(row => $"{row.GetProperty("protocol").GetString()}:{row.GetProperty("feature").GetString()}")
.Distinct(StringComparer.Ordinal)
.Count());
Assert.All(rows, row =>
{
AssertRequired(row, "currentStatus");
AssertRequired(row, "target");
AssertRequired(row, "authBoundary");
AssertRequired(row, "fixture");
AssertRequired(row, "testLocation");
AssertRequired(row, "notes");
});
}
[Fact]
public void SupportMatrix_EveryClaimedFixtureExistsAndContainsValidJson()
{
using var matrix = ReadFixture("protocol-support-matrix.json");
var fixtureDirectory = Path.Combine(
AppContext.BaseDirectory,
"Fixtures",
"Protocols");
foreach (var row in matrix.RootElement.EnumerateArray())
{
var rowName = $"{row.GetProperty("protocol").GetString()}:" +
row.GetProperty("feature").GetString();
var fixtures = row.GetProperty("fixture").GetString()!
.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
Assert.NotEmpty(fixtures);
foreach (var fixture in fixtures)
{
Assert.EndsWith(".json", fixture, StringComparison.Ordinal);
var path = Path.Combine(fixtureDirectory, fixture);
Assert.True(File.Exists(path), $"{rowName} references missing fixture {fixture}");
using var document = JsonDocument.Parse(File.ReadAllText(path));
Assert.NotEqual(JsonValueKind.Undefined, document.RootElement.ValueKind);
}
}
}
[Fact]
public void SupportMatrix_CurrentDurableAndScopedProtocolClaimsNameTheirRegressionTests()
{
using var matrix = ReadFixture("protocol-support-matrix.json");
var rows = matrix.RootElement.EnumerateArray().ToList();
var playlistUpdate = Assert.Single(rows, row =>
row.GetProperty("protocol").GetString() == "subsonic" &&
row.GetProperty("feature").GetString() == "playlist-update");
Assert.Equal("explicit", playlistUpdate.GetProperty("currentStatus").GetString());
Assert.Contains("exact tenant, owner", playlistUpdate.GetProperty("authBoundary").GetString(),
StringComparison.Ordinal);
Assert.Contains("SubsonicPlaylistMutationTests", playlistUpdate.GetProperty("testLocation").GetString(),
StringComparison.Ordinal);
Assert.All(rows.Where(row => row.GetProperty("feature").GetString() is "favorites" or "star-and-unstar"),
row => Assert.Contains("FavoriteActionPipelineTests", row.GetProperty("testLocation").GetString(),
StringComparison.Ordinal));
Assert.All(rows.Where(row => row.GetProperty("feature").GetString() is "playback-and-scrobbling" or "scrobble"),
row => Assert.Contains("PlaybackSignalPipelineTests", row.GetProperty("testLocation").GetString(),
StringComparison.Ordinal));
var routedRows = rows.Where(row =>
row.GetProperty("feature").GetString() is "search-and-browse" or "search3" or
"item-metadata-and-images" or "item-metadata-and-cover-art" or "streaming-and-ranges");
Assert.All(routedRows, row => Assert.Contains(
"ProtocolRouteFixtureTests",
row.GetProperty("testLocation").GetString(),
StringComparison.Ordinal));
Assert.DoesNotContain(rows, row => row.GetProperty("testLocation").GetString()!.Contains(
"ProtocolProviderGatewayContractTests",
StringComparison.Ordinal));
Assert.DoesNotContain(rows, row =>
row.GetProperty("authBoundary").GetString()!.Contains(
"does not yet select a provider account",
StringComparison.Ordinal));
}
[Fact]
public void JellyfinSearchAndInstantMix_DoNotUseFireAndForgetOrRandomOrdering()
{
var root = FindRepositoryRoot();
var search = File.ReadAllText(Path.Combine(root, "allstarr", "Controllers", "JellyfinController.Search.cs"));
var controller = File.ReadAllText(Path.Combine(root, "allstarr", "Controllers", "JellyfinController.cs"));
Assert.DoesNotContain("Task.Run", search, StringComparison.Ordinal);
Assert.DoesNotContain("new Random()", controller, StringComparison.Ordinal);
Assert.Contains("StableInstantMixOrder", controller, StringComparison.Ordinal);
Assert.Equal(
["IntelligenceController.AudioMuse.cs"],
Directory.EnumerateFiles(Path.Combine(root, "allstarr", "Controllers"), "*.cs")
.Where(file => File.ReadAllText(file).Contains("_audioMuse.StartAnalysisAsync(", StringComparison.Ordinal))
.Select(Path.GetFileName).Order(StringComparer.Ordinal));
}
[Fact]
public void Controllers_DoNotStartUntrackedTaskRunWork()
{
var directory = Path.Combine(FindRepositoryRoot(), "allstarr", "Controllers");
foreach (var file in Directory.EnumerateFiles(directory, "*.cs", SearchOption.TopDirectoryOnly))
Assert.DoesNotContain("Task.Run", File.ReadAllText(file), StringComparison.Ordinal);
}
private static void AssertRequired(JsonElement row, string property)
{
Assert.True(row.TryGetProperty(property, out var value), property);
Assert.False(string.IsNullOrWhiteSpace(value.GetString()), property);
}
private static string SynthesizedOperationPath(string template, string itemId) =>
Regex.Replace(
template
.Replace("{itemId}", itemId, StringComparison.OrdinalIgnoreCase)
.Replace("{imageType}", "Primary", StringComparison.OrdinalIgnoreCase)
.Replace("{imageIndex}", "0", StringComparison.OrdinalIgnoreCase)
.Replace("{tag}", "revision", StringComparison.OrdinalIgnoreCase)
.Replace("{format}", "jpg", StringComparison.OrdinalIgnoreCase)
.Replace("{maxWidth}", "300", StringComparison.OrdinalIgnoreCase)
.Replace("{maxHeight}", "300", StringComparison.OrdinalIgnoreCase)
.Replace("{percentPlayed}", "0", StringComparison.OrdinalIgnoreCase)
.Replace("{unplayedCount}", "0", StringComparison.OrdinalIgnoreCase),
"\\{[^}]+\\}",
"fixture-id");
private static List<(string Id, string Method, string Path)> ReadOperations(JsonElement openApi)
{
var methods = new HashSet<string>(
["GET", "PUT", "POST", "DELETE", "PATCH", "OPTIONS", "HEAD", "TRACE"],
StringComparer.Ordinal);
var operations = new List<(string Id, string Method, string Path)>();
foreach (var path in openApi.GetProperty("paths").EnumerateObject())
{
foreach (var method in path.Value.EnumerateObject())
{
if (!methods.Contains(method.Name.ToUpperInvariant())) continue;
operations.Add((
method.Value.GetProperty("operationId").GetString()!,
method.Name.ToUpperInvariant(),
path.Name));
}
}
return operations;
}
private static JsonDocument ReadFixture(string fileName)
{
var path = Path.Combine(AppContext.BaseDirectory, "Fixtures", "Protocols", fileName);
return JsonDocument.Parse(File.ReadAllText(path));
}
private static string FindRepositoryRoot()
{
var directory = new DirectoryInfo(AppContext.BaseDirectory);
while (directory != null && !File.Exists(Path.Combine(directory.FullName, "allstarr.sln")))
{
directory = directory.Parent;
}
return directory?.FullName ?? throw new DirectoryNotFoundException("Could not locate allstarr.sln");
}
}