using System.Net; using System.Text; 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 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); } /// 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. /// 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. [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. } }