feat: add authenticated live podcasts and simplify native video playback
Include podcast capture, ingestion and replay, dependency and installer updates, and native video positioning after navigation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
parent
d9b183c500
commit
4fc91cec3e
45 changed files with 2330 additions and 378 deletions
|
|
@ -72,6 +72,9 @@ public sealed class ApiWebServerFixture : WebHostFixture
|
|||
options.ClaimsIdentity.RoleClaimType = Yavsc.Constants.RoleClaimType)
|
||||
.AddRoles<IdentityRole>().AddEntityFrameworkStores<ApplicationDbContext>();
|
||||
builder.Services.AddSiteAdministration();
|
||||
var podcastRoot = Path.Combine(Path.GetTempPath(), $"yavsc-api-podcasts-{Guid.NewGuid():N}");
|
||||
builder.Services.AddPodcasts(builder.Configuration);
|
||||
builder.Services.Configure<PodcastOptions>(options => options.RootPath = podcastRoot);
|
||||
builder.Services.Configure<GoogleAuthSettings>(_ => { });
|
||||
builder.Services.Configure<SiteSettings>(settings => settings.Authority = "example.test");
|
||||
builder.Services.AddTransient<IBillingService, BillingService>();
|
||||
|
|
@ -82,6 +85,7 @@ public sealed class ApiWebServerFixture : WebHostFixture
|
|||
builder.Services.AddTransient<IYavscMessageSender, YavscMessageSender>();
|
||||
builder.Services.AddAuthorization(options =>
|
||||
{
|
||||
options.AddPolicy("ApiScope", policy => policy.RequireAuthenticatedUser().RequireClaim("scope", "api"));
|
||||
options.AddPolicy("AdministratorOnly", policy =>
|
||||
{
|
||||
policy.RequireAuthenticatedUser();
|
||||
|
|
@ -107,7 +111,12 @@ public sealed class ApiWebServerFixture : WebHostFixture
|
|||
};
|
||||
});
|
||||
|
||||
return builder.Build();
|
||||
var app = builder.Build();
|
||||
app.Lifetime.ApplicationStopped.Register(() =>
|
||||
{
|
||||
if (Directory.Exists(podcastRoot)) Directory.Delete(podcastRoot, recursive: true);
|
||||
});
|
||||
return app;
|
||||
}
|
||||
|
||||
protected override async Task<WebApplication> ConfigurePipelineAsync(WebApplication app)
|
||||
|
|
|
|||
267
src/test/Yavsc.Api.Tests/LiveProcessorTests.cs
Normal file
267
src/test/Yavsc.Api.Tests/LiveProcessorTests.cs
Normal file
|
|
@ -0,0 +1,267 @@
|
|||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Http.Features;
|
||||
using System.Net.WebSockets;
|
||||
using Yavsc.Models;
|
||||
using Yavsc.Abstract.Streaming;
|
||||
using Yavsc.Services;
|
||||
|
||||
namespace Yavsc.Api.Test;
|
||||
|
||||
public sealed class LiveProcessorTests : IDisposable
|
||||
{
|
||||
private readonly string _root = Path.Combine(Path.GetTempPath(), $"yavsc-podcast-unit-{Guid.NewGuid():N}");
|
||||
private readonly List<LiveProcessor> _processors = new();
|
||||
private readonly CancellationToken _ct = TestContext.Current.CancellationToken;
|
||||
|
||||
private LiveProcessor New(Action<PodcastOptions>? configure = null, TimeProvider? clock = null)
|
||||
{
|
||||
var options = new PodcastOptions { RootPath = _root };
|
||||
configure?.Invoke(options);
|
||||
var processor = new LiveProcessor(NullLoggerFactory.Instance, Options.Create(options), clock);
|
||||
_processors.Add(processor);
|
||||
return processor;
|
||||
}
|
||||
|
||||
internal static byte[] Packets(int count = 2, byte marker = 1)
|
||||
{
|
||||
var bytes = Enumerable.Repeat(marker, 188 * count).ToArray();
|
||||
for (var i = 0; i < bytes.Length; i += 188) bytes[i] = 0x47;
|
||||
return bytes;
|
||||
}
|
||||
|
||||
private Task<PodcastResponse> Append(LiveProcessor processor, Guid id, int sequence, byte[]? bytes = null,
|
||||
double duration = 2, string owner = "alice")
|
||||
=> processor.AppendAsync(id, owner, sequence, duration, new MemoryStream(bytes ?? Packets()), _ct);
|
||||
|
||||
[Fact]
|
||||
public async Task Complete_and_restart_preserve_order_bytes_and_metadata()
|
||||
{
|
||||
var processor = New();
|
||||
var podcast = await processor.CreateAsync("alice", "Alice", " Concert ", _ct);
|
||||
var first = Packets(2, 11);
|
||||
var second = Packets(3, 12);
|
||||
await Append(processor, podcast.Id, 0, first);
|
||||
await Append(processor, podcast.Id, 1, second, 2.5);
|
||||
var completed = await processor.CompleteAsync(podcast.Id, "alice", _ct);
|
||||
Assert.Equal(PodcastState.Completed, completed.State);
|
||||
Assert.Equal(4.5, completed.DurationSeconds);
|
||||
Assert.Equal(first.Length + second.Length, completed.Bytes);
|
||||
var restored = New();
|
||||
var manifest = await restored.GetAsync(podcast.Id, _ct);
|
||||
Assert.Equal("Concert", manifest.Podcast.Title);
|
||||
Assert.Equal(PodcastState.Completed, manifest.Podcast.State);
|
||||
Assert.Equal(new[] { 0, 1 }, manifest.Segments.Select(s => s.Sequence));
|
||||
await using var stream = await restored.OpenSegmentAsync(podcast.Id, 1, _ct);
|
||||
using var actual = new MemoryStream();
|
||||
await stream.CopyToAsync(actual, _ct);
|
||||
Assert.Equal(second, actual.ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Ordered_ingestion_is_idempotent_and_conflicting_retries_are_rejected()
|
||||
{
|
||||
var processor = New();
|
||||
var podcast = await processor.CreateAsync("alice", "Alice", "Live", _ct);
|
||||
Assert.Equal(409, (await Assert.ThrowsAsync<PodcastException>(() => Append(processor, podcast.Id, 1))).StatusCode);
|
||||
var first = await Append(processor, podcast.Id, 0);
|
||||
Assert.Equal(first, await Append(processor, podcast.Id, 0));
|
||||
Assert.Equal(409, (await Assert.ThrowsAsync<PodcastException>(
|
||||
() => Append(processor, podcast.Id, 0, Packets(marker: 2)))).StatusCode);
|
||||
Assert.Equal(409, (await Assert.ThrowsAsync<PodcastException>(
|
||||
() => Append(processor, podcast.Id, 0, duration: 3))).StatusCode);
|
||||
await processor.CompleteAsync(podcast.Id, "alice", _ct);
|
||||
Assert.Equal(1, (await Append(processor, podcast.Id, 0)).SegmentCount);
|
||||
Assert.Equal(409, (await Assert.ThrowsAsync<PodcastException>(() => Append(processor, podcast.Id, 1))).StatusCode);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(-1)]
|
||||
[InlineData(15.01)]
|
||||
[InlineData(double.NaN)]
|
||||
[InlineData(double.PositiveInfinity)]
|
||||
public async Task Invalid_durations_never_commit(double duration)
|
||||
{
|
||||
var processor = New();
|
||||
var podcast = await processor.CreateAsync("alice", "Alice", "Live", _ct);
|
||||
Assert.Equal(400, (await Assert.ThrowsAsync<PodcastException>(
|
||||
() => Append(processor, podcast.Id, 0, duration: duration))).StatusCode);
|
||||
Assert.Empty((await processor.GetAsync(podcast.Id, _ct)).Segments);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invalid_empty_and_oversized_transport_streams_never_commit()
|
||||
{
|
||||
var processor = New(o => { o.MaxSegmentBytes = 376; o.MaxOwnerBytes = 752; });
|
||||
var podcast = await processor.CreateAsync("alice", "Alice", "Live", _ct);
|
||||
foreach (var bytes in new[] { Array.Empty<byte>(), new byte[188], Packets()[..^1] })
|
||||
Assert.Equal(400, (await Assert.ThrowsAsync<PodcastException>(() => Append(processor, podcast.Id, 0, bytes))).StatusCode);
|
||||
Assert.Equal(413, (await Assert.ThrowsAsync<PodcastException>(
|
||||
() => Append(processor, podcast.Id, 0, Packets(3)))).StatusCode);
|
||||
Assert.Empty((await processor.GetAsync(podcast.Id, _ct)).Segments);
|
||||
Assert.Equal(376, (await Append(processor, podcast.Id, 0, Packets(2))).Bytes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Owner_quota_is_exact_shared_across_replays_and_released_on_delete()
|
||||
{
|
||||
var processor = New(o => { o.MaxSegmentBytes = 376; o.MaxOwnerBytes = 564; });
|
||||
var first = await processor.CreateAsync("alice", "Alice", "One", _ct);
|
||||
await Append(processor, first.Id, 0, Packets(2));
|
||||
await processor.CompleteAsync(first.Id, "alice", _ct);
|
||||
var second = await processor.CreateAsync("alice", "Alice", "Two", _ct);
|
||||
await Append(processor, second.Id, 0, Packets(1));
|
||||
Assert.Equal(413, (await Assert.ThrowsAsync<PodcastException>(
|
||||
() => Append(processor, second.Id, 1, Packets(1)))).StatusCode);
|
||||
await processor.DeleteAsync(first.Id, "alice", _ct);
|
||||
Assert.Equal(376, (await Append(processor, second.Id, 1, Packets(1))).Bytes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Ownership_and_one_live_per_owner_are_enforced()
|
||||
{
|
||||
var processor = New();
|
||||
var podcast = await processor.CreateAsync("alice", "Alice", "One", _ct);
|
||||
Assert.Equal(409, (await Assert.ThrowsAsync<PodcastException>(
|
||||
() => processor.CreateAsync("alice", "Alice", "Two", _ct))).StatusCode);
|
||||
Assert.Equal(403, (await Assert.ThrowsAsync<PodcastException>(
|
||||
() => Append(processor, podcast.Id, 0, owner: "bob"))).StatusCode);
|
||||
Assert.Equal(403, (await Assert.ThrowsAsync<PodcastException>(
|
||||
() => processor.CompleteAsync(podcast.Id, "bob", _ct))).StatusCode);
|
||||
Assert.Equal(403, (await Assert.ThrowsAsync<PodcastException>(
|
||||
() => processor.DeleteAsync(podcast.Id, "bob", _ct))).StatusCode);
|
||||
Assert.Equal(409, (await Assert.ThrowsAsync<PodcastException>(
|
||||
() => processor.DeleteAsync(podcast.Id, "alice", _ct))).StatusCode);
|
||||
await processor.CreateAsync("bob", "Bob", "Other user", _ct);
|
||||
Assert.Equal(2, (await processor.ListAsync(_ct)).Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Concurrent_identical_uploads_commit_only_once()
|
||||
{
|
||||
var processor = New();
|
||||
var podcast = await processor.CreateAsync("alice", "Alice", "One", _ct);
|
||||
await Task.WhenAll(Enumerable.Range(0, 8).Select(_ => Append(processor, podcast.Id, 0)));
|
||||
var manifest = await processor.GetAsync(podcast.Id, _ct);
|
||||
Assert.Single(manifest.Segments);
|
||||
Assert.Equal(376, manifest.Podcast.Bytes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Restart_or_idle_timeout_ends_live_broadcast_but_keeps_its_segments()
|
||||
{
|
||||
var clock = new Clock();
|
||||
var processor = New(clock: clock);
|
||||
var first = await processor.CreateAsync("alice", "Alice", "One", _ct);
|
||||
await Append(processor, first.Id, 0);
|
||||
clock.Now += TimeSpan.FromSeconds(61);
|
||||
Assert.Equal(PodcastState.Interrupted, (await processor.GetAsync(first.Id, _ct)).Podcast.State);
|
||||
var second = await processor.CreateAsync("alice", "Alice", "Two", _ct);
|
||||
await Append(processor, second.Id, 0);
|
||||
var restarted = New(clock: clock);
|
||||
var replay = await restarted.GetAsync(second.Id, _ct);
|
||||
Assert.Equal(PodcastState.Interrupted, replay.Podcast.State);
|
||||
Assert.Single(replay.Segments);
|
||||
await using var segment = await restarted.OpenSegmentAsync(second.Id, 0, _ct);
|
||||
Assert.Equal(376, segment.Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Segment_and_podcast_count_limits_are_enforced()
|
||||
{
|
||||
var processor = New(o => { o.MaxSegments = 1; o.MaxOwnerPodcasts = 1; });
|
||||
var first = await processor.CreateAsync("alice", "Alice", "One", _ct);
|
||||
await Append(processor, first.Id, 0);
|
||||
Assert.Equal(413, (await Assert.ThrowsAsync<PodcastException>(() => Append(processor, first.Id, 1))).StatusCode);
|
||||
await processor.CompleteAsync(first.Id, "alice", _ct);
|
||||
Assert.Equal(413, (await Assert.ThrowsAsync<PodcastException>(
|
||||
() => processor.CreateAsync("alice", "Alice", "Two", _ct))).StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Cancelled_upload_does_not_publish_partial_bytes_and_can_be_retried()
|
||||
{
|
||||
var processor = New();
|
||||
var podcast = await processor.CreateAsync("alice", "Alice", "One", _ct);
|
||||
using var cancelled = new CancellationTokenSource();
|
||||
using var input = new CancellingStream(Packets(), cancelled);
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => processor.AppendAsync(
|
||||
podcast.Id, "alice", 0, 2, input, cancelled.Token));
|
||||
Assert.Equal(188, input.Position);
|
||||
Assert.Empty((await processor.GetAsync(podcast.Id, _ct)).Segments);
|
||||
Assert.Equal(1, (await Append(processor, podcast.Id, 0)).SegmentCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Legacy_websocket_preserves_different_frame_lengths_and_cleans_up()
|
||||
{
|
||||
var processor = New();
|
||||
Directory.CreateDirectory(_root);
|
||||
var first = new byte[] { 1, 2, 3 };
|
||||
var second = new byte[] { 4, 5, 6, 7, 8 };
|
||||
using var socket = new IncomingSocket(first, second);
|
||||
var context = new DefaultHttpContext();
|
||||
context.Features.Set<IHttpWebSocketFeature>(socket);
|
||||
var user = new ApplicationUser { UserName = "legacy", DiskQuota = 1024, DiskUsage = 0 };
|
||||
Assert.True(await processor.AcceptStream(context, user, _root, "legacy.ts"));
|
||||
Assert.Equal(first.Concat(second), await File.ReadAllBytesAsync(Path.Combine(_root, "legacy.ts"), _ct));
|
||||
Assert.Empty(processor.Casters);
|
||||
Assert.Equal(8, user.DiskUsage);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var processor in _processors) processor.Dispose();
|
||||
if (Directory.Exists(_root)) Directory.Delete(_root, recursive: true);
|
||||
}
|
||||
|
||||
private sealed class Clock : TimeProvider
|
||||
{
|
||||
public DateTimeOffset Now { get; set; } = DateTimeOffset.UtcNow;
|
||||
public override DateTimeOffset GetUtcNow() => Now;
|
||||
}
|
||||
|
||||
private sealed class CancellingStream(byte[] bytes, CancellationTokenSource cancellation) : MemoryStream(bytes)
|
||||
{
|
||||
public override ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var count = Read(buffer.Span[..Math.Min(188, buffer.Length)]);
|
||||
cancellation.Cancel();
|
||||
return ValueTask.FromResult(count);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class IncomingSocket(params byte[][] frames) : WebSocket, IHttpWebSocketFeature
|
||||
{
|
||||
private int _index;
|
||||
private WebSocketState _state = WebSocketState.Open;
|
||||
public bool IsWebSocketRequest => true;
|
||||
public Task<WebSocket> AcceptAsync(WebSocketAcceptContext context) => Task.FromResult<WebSocket>(this);
|
||||
public override WebSocketCloseStatus? CloseStatus => _state == WebSocketState.CloseReceived ? WebSocketCloseStatus.NormalClosure : null;
|
||||
public override string? CloseStatusDescription => null;
|
||||
public override WebSocketState State => _state;
|
||||
public override string? SubProtocol => null;
|
||||
public override void Abort() => _state = WebSocketState.Aborted;
|
||||
public override Task CloseAsync(WebSocketCloseStatus closeStatus, string? statusDescription, CancellationToken cancellationToken)
|
||||
{ _state = WebSocketState.Closed; return Task.CompletedTask; }
|
||||
public override Task CloseOutputAsync(WebSocketCloseStatus closeStatus, string? statusDescription, CancellationToken cancellationToken)
|
||||
=> CloseAsync(closeStatus, statusDescription, cancellationToken);
|
||||
public override void Dispose() => _state = WebSocketState.Closed;
|
||||
public override Task<WebSocketReceiveResult> ReceiveAsync(ArraySegment<byte> buffer, CancellationToken cancellationToken)
|
||||
{
|
||||
if (_index == frames.Length)
|
||||
{
|
||||
_state = WebSocketState.CloseReceived;
|
||||
return Task.FromResult(new WebSocketReceiveResult(0, WebSocketMessageType.Close, true, WebSocketCloseStatus.NormalClosure, null));
|
||||
}
|
||||
var frame = frames[_index++];
|
||||
frame.CopyTo(buffer.AsSpan());
|
||||
return Task.FromResult(new WebSocketReceiveResult(frame.Length, WebSocketMessageType.Binary, true));
|
||||
}
|
||||
public override Task SendAsync(ArraySegment<byte> buffer, WebSocketMessageType messageType, bool endOfMessage, CancellationToken cancellationToken)
|
||||
=> Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
101
src/test/Yavsc.Api.Tests/PodcastControllerTests.cs
Normal file
101
src/test/Yavsc.Api.Tests/PodcastControllerTests.cs
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Security.Claims;
|
||||
using System.Text.Json;
|
||||
using Yavsc.Abstract.Streaming;
|
||||
using Yavsc.Api.Test.Fixtures;
|
||||
using Yavsc.Tests.Shared;
|
||||
|
||||
namespace Yavsc.Api.Test;
|
||||
|
||||
[Collection("Yavsc Api")]
|
||||
public sealed class PodcastControllerTests(ApiWebServerFixture fixture) : IClassFixture<ApiWebServerFixture>
|
||||
{
|
||||
private readonly CancellationToken _ct = TestContext.Current.CancellationToken;
|
||||
|
||||
private HttpClient Client(string? user = "alice", string scope = "api")
|
||||
{
|
||||
var client = new HttpClient(new HttpClientHandler
|
||||
{
|
||||
ServerCertificateCustomValidationCallback = (_, _, _, _) => true
|
||||
}) { BaseAddress = new Uri(fixture.BaseAddress) };
|
||||
if (user is not null)
|
||||
client.DefaultRequestHeaders.Authorization = new("Bearer", TestTokenIssuer.Issue(user, scope));
|
||||
return client;
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("/api/v1/podcasts")]
|
||||
[InlineData("/api/v1/podcasts/00000000-0000-0000-0000-000000000000")]
|
||||
[InlineData("/api/v1/podcasts/00000000-0000-0000-0000-000000000000/index.m3u8")]
|
||||
[InlineData("/api/v1/podcasts/00000000-0000-0000-0000-000000000000/segments/0.ts")]
|
||||
public async Task Metadata_playlists_and_segments_are_never_anonymous(string path)
|
||||
{
|
||||
using var anonymous = Client(null);
|
||||
using var response = await anonymous.GetAsync(path, _ct);
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
using var wrongScope = Client("bob", "profile");
|
||||
using var forbidden = await wrongScope.GetAsync(path, _ct);
|
||||
Assert.Equal(HttpStatusCode.Forbidden, forbidden.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Live_ingestion_playback_completion_and_deletion_work_over_http()
|
||||
{
|
||||
using var owner = Client($"podcast-owner-{Guid.NewGuid():N}");
|
||||
using var listener = Client("podcast-listener");
|
||||
using var created = await owner.PostAsJsonAsync("/api/v1/podcasts", new CreatePodcastRequest { Title = "Concert" }, _ct);
|
||||
Assert.Equal(HttpStatusCode.Created, created.StatusCode);
|
||||
var podcast = await created.Content.ReadFromJsonAsync<PodcastResponse>(_ct);
|
||||
Assert.NotNull(podcast);
|
||||
Assert.NotNull(created.Headers.Location);
|
||||
var path = $"/api/v1/podcasts/{podcast.Id}";
|
||||
using var content = new ByteArrayContent(LiveProcessorTests.Packets());
|
||||
content.Headers.ContentType = new MediaTypeHeaderValue("video/mp2t");
|
||||
using var appended = await owner.PutAsync($"{path}/segments/0?duration=2.5", content, _ct);
|
||||
Assert.Equal(HttpStatusCode.OK, appended.StatusCode);
|
||||
var live = await listener.GetStringAsync($"{path}/index.m3u8", _ct);
|
||||
Assert.StartsWith("#EXTM3U", live);
|
||||
Assert.Contains("#EXTINF:2.5,\nsegments/0.ts", live);
|
||||
Assert.DoesNotContain("#EXT-X-ENDLIST", live);
|
||||
Assert.Equal(LiveProcessorTests.Packets(), await listener.GetByteArrayAsync($"{path}/segments/0.ts", _ct));
|
||||
using var denied = await listener.PostAsync($"{path}/complete", null, _ct);
|
||||
Assert.Equal(HttpStatusCode.Forbidden, denied.StatusCode);
|
||||
using var completed = await owner.PostAsync($"{path}/complete", null, _ct);
|
||||
Assert.Equal(HttpStatusCode.OK, completed.StatusCode);
|
||||
var replay = await listener.GetStringAsync($"{path}/index.m3u8", _ct);
|
||||
Assert.Contains("#EXT-X-ENDLIST", replay);
|
||||
using var deleteDenied = await listener.DeleteAsync(path, _ct);
|
||||
Assert.Equal(HttpStatusCode.Forbidden, deleteDenied.StatusCode);
|
||||
using var deleted = await owner.DeleteAsync(path, _ct);
|
||||
Assert.Equal(HttpStatusCode.NoContent, deleted.StatusCode);
|
||||
using var missing = await listener.GetAsync(path, _ct);
|
||||
Assert.Equal(HttpStatusCode.NotFound, missing.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invalid_title_is_a_validation_problem()
|
||||
{
|
||||
using var owner = Client();
|
||||
using var response = await owner.PostAsJsonAsync("/api/v1/podcasts", new { title = " " }, _ct);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
Assert.Contains("application/problem+json", response.Content.Headers.ContentType?.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OpenApi_exposes_podcast_routes_without_breaking_document_generation()
|
||||
{
|
||||
using var admin = Client("podcast-admin");
|
||||
admin.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer",
|
||||
TestTokenIssuer.Issue("podcast-admin", "api",
|
||||
extraClaims: new[] { new Claim(Constants.RoleClaimType, Constants.AdminGroupName) }));
|
||||
using var response = await admin.GetAsync("/openapi/podcasts.json", _ct);
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync(_ct));
|
||||
var paths = document.RootElement.GetProperty("paths");
|
||||
Assert.True(paths.TryGetProperty("/api/v1/podcasts", out _));
|
||||
Assert.True(paths.TryGetProperty("/api/v1/podcasts/{id}/segments/{sequence}", out _));
|
||||
Assert.True(paths.TryGetProperty("/api/v1/podcasts/{id}/index.m3u8", out _));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue