Files
LlamaCasty/ytLive.Tests/YouTubeAuthServiceTests.cs
T
gramps 967df83925 tests: one source-assertion helper, so the prose trap can't recur
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.
2026-09-28 11:53:52 -07:00

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"));
}
}