The trap I hit twice today — a file-wide DoesNotContain matching the doc comment that explains the ban — was possible because three tests each hand-rolled their own File.ReadAllText + path-walker, two of them mine. Fixed at the root rather than per-test: SourceAssert now owns path walking and literal extraction, with Literal(memberName, params parts) and Scopes() that assert on the parsed VALUE. Tests that genuinely assert structure (the #if DEBUG arm in InstanceIsolationTests, the absence of a whole API in the locator) still read the file themselves, because those claims cannot be reduced to a value — the helper is for literal facts only. Shipping the fix introduced two defects, both caught by the build: a method named Path shadowed System.IO.Path and broke the error message that names the missing file, and Literal(fileName, memberName) invited a call that silently resolved to a nonexistent path. Argument order is now required-context-first so a wrong path is visible at the call site. Suite 380/380, 0 warnings. LayerReorderPersistenceTests.RealMouseDrag flaked once mid-session with no ytLive instance running and passed in isolation and on re-run — the environment flake already recorded in HANDOFF.md, not a regression from this change.
149 lines
6.3 KiB
C#
149 lines
6.3 KiB
C#
using System.IO;
|
|
using System.Linq;
|
|
using System.Net;
|
|
using System.Text;
|
|
using System.Text.RegularExpressions;
|
|
using Xunit;
|
|
using ytLive.Models;
|
|
using ytLive.Services;
|
|
|
|
namespace ytLive.Tests;
|
|
|
|
public class YouTubeAuthServiceTests
|
|
{
|
|
private sealed class FakeHandler : HttpMessageHandler
|
|
{
|
|
private readonly string _tokenResponse;
|
|
private readonly string _channelResponse;
|
|
|
|
public FakeHandler(string tokenResponse, string channelResponse)
|
|
{
|
|
_tokenResponse = tokenResponse;
|
|
_channelResponse = channelResponse;
|
|
}
|
|
|
|
protected override Task<HttpResponseMessage> SendAsync(
|
|
HttpRequestMessage request, CancellationToken cancellationToken)
|
|
{
|
|
var isToken = request.RequestUri!.PathAndQuery.Contains("/token");
|
|
var body = isToken ? _tokenResponse : _channelResponse;
|
|
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
|
|
{
|
|
Content = new StringContent(body, Encoding.UTF8, "application/json"),
|
|
});
|
|
}
|
|
}
|
|
|
|
private static YouTubeAuthService CreateService(string tokenResponse, string channelResponse) =>
|
|
new("test-id", "test-secret", new HttpClient(new FakeHandler(tokenResponse, channelResponse)));
|
|
|
|
[Fact]
|
|
public async Task ExchangeCodeForToken_MockServer_ParsesChannel()
|
|
{
|
|
var service = CreateService(
|
|
"""{"access_token":"acc-123","refresh_token":"ref-123","expires_in":3600}""",
|
|
"""{"items":[{"id":"UC123","snippet":{"title":"Test Channel","thumbnails":{"default":{"url":"http://example/thumb.jpg"}}}}]}""");
|
|
|
|
var channel = await service.ExchangeCodeForToken("auth-code", "http://localhost:8765/oauth2/callback");
|
|
|
|
Assert.NotNull(channel);
|
|
Assert.Equal("UC123", channel!.ChannelId);
|
|
Assert.Equal("Test Channel", channel.DisplayName);
|
|
Assert.Equal("http://example/thumb.jpg", channel.ProfileImageUrl);
|
|
Assert.Equal("acc-123", channel.AccessToken);
|
|
Assert.Equal("ref-123", channel.RefreshToken);
|
|
Assert.InRange(channel.TokenExpiry, DateTime.UtcNow.AddSeconds(3590), DateTime.UtcNow.AddSeconds(3610));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RefreshToken_MockServer_UpdatesAccessTokenAndExpiry()
|
|
{
|
|
var service = CreateService(
|
|
"""{"access_token":"new-acc","expires_in":3600}""",
|
|
"""{"items":[]}""");
|
|
service.SetSession(new YouTubeChannel
|
|
{
|
|
RefreshToken = "ref-123",
|
|
TokenExpiry = DateTime.UtcNow.AddMinutes(-5),
|
|
});
|
|
|
|
var ok = await service.RefreshToken();
|
|
|
|
Assert.True(ok);
|
|
Assert.Equal("new-acc", service.CurrentChannel!.AccessToken);
|
|
Assert.InRange(service.CurrentChannel.TokenExpiry,
|
|
DateTime.UtcNow.AddSeconds(3590), DateTime.UtcNow.AddSeconds(3610));
|
|
}
|
|
|
|
[Fact]
|
|
public void ClearSession_DropsCurrentChannel()
|
|
{
|
|
var service = CreateService("""{"access_token":"a","refresh_token":"r","expires_in":3600}""",
|
|
"""{"items":[]}""");
|
|
service.SetSession(new YouTubeChannel { DisplayName = "Test Channel" });
|
|
|
|
service.ClearSession();
|
|
|
|
Assert.Null(service.CurrentChannel);
|
|
}
|
|
|
|
/// <summary>Creator report 2026-09-27: clicking "Change Account", cancelling in the
|
|
/// Google account chooser, then clicking again failed with "cannot sign-in because
|
|
/// the callback url was not released". Abandoning the browser gives the app no
|
|
/// signal, so the first attempt sat on the loopback callback port for its full
|
|
/// 5-minute timeout and the second attempt could not bind.
|
|
/// <para>Requires nothing else to be listening on port 8765 — close a running
|
|
/// LlamaCasty that happens to be mid-sign-in, or this test sees a genuine
|
|
/// "address already in use" from the host.</para></summary>
|
|
[Fact]
|
|
public async Task RetryingAfterAnAbandonedAttempt_ReleasesTheCallbackPort()
|
|
{
|
|
var service = new YouTubeAuthService(
|
|
"test-id", "test-secret",
|
|
new HttpClient(new FakeHandler("{}", "{}")),
|
|
openBrowser: _ => { }); // never shell out to a real browser from a test
|
|
|
|
var first = service.AuthenticateAsync();
|
|
|
|
// The creator gives up on the chooser and clicks "Change Account" again.
|
|
var second = service.AuthenticateAsync();
|
|
|
|
// The abandoned attempt must be released promptly rather than left holding the
|
|
// port for its whole 5-minute timeout. If it weren't, this await would time out.
|
|
Assert.Null(await first.WaitAsync(TimeSpan.FromSeconds(15)));
|
|
|
|
// Taking over again proves `second` really did bind the port: had it not,
|
|
// awaiting it would throw HttpListenerException ("address already in use").
|
|
_ = service.AuthenticateAsync();
|
|
Assert.Null(await second.WaitAsync(TimeSpan.FromSeconds(15)));
|
|
|
|
// The last attempt is deliberately left pending — nothing can cancel it and the
|
|
// test host exits before its 5-minute timer fires.
|
|
}
|
|
|
|
/// <summary>
|
|
/// The app requests exactly ONE scope, and it is the broad one. 2026-09-28:
|
|
/// `auth/youtube.force-ssl` was dropped as redundant — its grant ("see, edit, and
|
|
/// permanently delete your videos, ratings, comments and captions") is a strict subset of
|
|
/// `auth/youtube` ("Manage your YouTube account"), which is also the only scope that can
|
|
/// cover live broadcast create/transition, so it was never avoidable.
|
|
///
|
|
/// Asserted from source because the const is private and the auth flow cannot be exercised
|
|
/// without a browser. What is worth locking in is the DECISION — that the redundant scope
|
|
/// does not come back — not merely today's string.
|
|
///
|
|
/// The assertion reads the const's <b>value</b> out of the source rather than scanning the
|
|
/// file. A file-wide `DoesNotContain("force-ssl")` fails on this very file's doc comment,
|
|
/// which names force-ssl precisely to explain why it went. A test that fails on the prose
|
|
/// documenting the guarantee gets deleted instead of obeyed.
|
|
/// </summary>
|
|
[Fact]
|
|
public void ExactlyOneScopeIsRequested_AndItIsTheBroadOne()
|
|
{
|
|
Assert.Equal(
|
|
new[] { "https://www.googleapis.com/auth/youtube" },
|
|
SourceAssert.Scopes("Scope", "Services", "YouTubeAuthService.cs"));
|
|
}
|
|
|
|
}
|