diff --git a/WeatherExtension.Tests/ConnectivityProbeTests.cs b/WeatherExtension.Tests/ConnectivityProbeTests.cs new file mode 100644 index 0000000..495ba0d --- /dev/null +++ b/WeatherExtension.Tests/ConnectivityProbeTests.cs @@ -0,0 +1,141 @@ +// Copyright (c) Bald Bearded Builder LLC +// Bald Bearded Builder LLC licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CmdPal.Ext.Weather.Services; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.CmdPal.Ext.Weather.UnitTests; + +[TestClass] +public class ConnectivityProbeTests +{ + [TestMethod] + public async Task GetCurrentWeatherAsync_ApiReturns500_TriggersProbe() + { + var callCount = 0; + var handler = new CountingHttpHandler(request => + { + callCount++; + return new HttpResponseMessage(HttpStatusCode.InternalServerError); + }); + + using var service = new OpenMeteoService(handler); + var result = await service.GetCurrentWeatherAsync(52.52, 13.41, ct: CancellationToken.None); + + Assert.IsNull(result); + // 1 API call + 1 connectivity probe = 2 + Assert.AreEqual(2, callCount, "Expected API call + connectivity probe"); + } + + [TestMethod] + public async Task GetCurrentWeatherAsync_ApiThrows_TriggersProbe() + { + var callCount = 0; + var handler = new CountingHttpHandler(request => + { + callCount++; + throw new HttpRequestException("Connection refused"); + }); + + using var service = new OpenMeteoService(handler); + var result = await service.GetCurrentWeatherAsync(52.52, 13.41, ct: CancellationToken.None); + + Assert.IsNull(result); + // 1 failed API call + 1 probe attempt (also fails since same handler throws) = 2 + Assert.AreEqual(2, callCount, "Expected API call + connectivity probe attempt"); + } + + [TestMethod] + public async Task GetForecastAsync_ApiReturns500_TriggersProbe() + { + var callCount = 0; + var handler = new CountingHttpHandler(request => + { + callCount++; + return new HttpResponseMessage(HttpStatusCode.InternalServerError); + }); + + using var service = new OpenMeteoService(handler); + var result = await service.GetForecastAsync(52.52, 13.41, ct: CancellationToken.None); + + Assert.IsNull(result); + Assert.AreEqual(2, callCount, "Expected API call + connectivity probe"); + } + + [TestMethod] + public async Task GetHourlyForecastAsync_ApiReturns500_TriggersProbe() + { + var callCount = 0; + var handler = new CountingHttpHandler(request => + { + callCount++; + return new HttpResponseMessage(HttpStatusCode.InternalServerError); + }); + + using var service = new OpenMeteoService(handler); + var result = await service.GetHourlyForecastAsync(52.52, 13.41, ct: CancellationToken.None); + + Assert.IsNull(result); + Assert.AreEqual(2, callCount, "Expected API call + connectivity probe"); + } + + [TestMethod] + public async Task GetCurrentWeatherAsync_ApiSucceeds_NoProbe() + { + var callCount = 0; + var handler = new CountingHttpHandler(request => + { + callCount++; + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("{}"), + }; + }); + + using var service = new OpenMeteoService(handler); + _ = await service.GetCurrentWeatherAsync(52.52, 13.41, ct: CancellationToken.None); + + // Only the API call, no probe + Assert.AreEqual(1, callCount, "Successful API call should not trigger probe"); + } + + [TestMethod] + public async Task GetCurrentWeatherAsync_Cancelled_NoProbe() + { + var callCount = 0; + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + var handler = new CountingHttpHandler(request => + { + callCount++; + throw new OperationCanceledException(); + }); + + using var service = new OpenMeteoService(handler); + + // HttpClient wraps OperationCanceledException as TaskCanceledException (a subtype). + // Use try/catch to accept any OperationCanceledException subtype. + Exception? thrown = null; + try + { + await service.GetCurrentWeatherAsync(52.52, 13.41, ct: cts.Token); + Assert.Fail("Expected OperationCanceledException to be thrown"); + } + catch (OperationCanceledException ex) + { + thrown = ex; + } + + Assert.IsNotNull(thrown, "Expected OperationCanceledException (or subtype)"); + // Cancelled — probe should not fire + Assert.AreEqual(0, callCount, "Cancelled request should not trigger probe"); + } +} + diff --git a/WeatherExtension/Properties/Resources.Designer.cs b/WeatherExtension/Properties/Resources.Designer.cs index dad66af..bdfb617 100644 --- a/WeatherExtension/Properties/Resources.Designer.cs +++ b/WeatherExtension/Properties/Resources.Designer.cs @@ -60,6 +60,51 @@ internal Resources() { } } + /// + /// Looks up a localized string similar to Weather API unreachable — may be blocked in your region. + /// + public static string connectivity_api_blocked { + get { + return ResourceManager.GetString("connectivity_api_blocked", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to current weather. + /// + public static string connectivity_endpoint_current_weather { + get { + return ResourceManager.GetString("connectivity_endpoint_current_weather", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to forecast. + /// + public static string connectivity_endpoint_forecast { + get { + return ResourceManager.GetString("connectivity_endpoint_forecast", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to hourly forecast. + /// + public static string connectivity_endpoint_hourly_forecast { + get { + return ResourceManager.GetString("connectivity_endpoint_hourly_forecast", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to No internet connection. + /// + public static string connectivity_no_internet { + get { + return ResourceManager.GetString("connectivity_no_internet", resourceCulture); + } + } + /// /// Looks up a localized string similar to Celsius. /// diff --git a/WeatherExtension/Properties/Resources.resx b/WeatherExtension/Properties/Resources.resx index 1cf54c5..87ada64 100644 --- a/WeatherExtension/Properties/Resources.resx +++ b/WeatherExtension/Properties/Resources.resx @@ -252,6 +252,21 @@ If you continue to have issues, please [file a bug](https://github.com/michaeljo 12 hours + + No internet connection + + + Weather API unreachable — may be blocked in your region + + + current weather + + + forecast + + + hourly forecast + Favorites diff --git a/WeatherExtension/Services/OpenMeteoService.cs b/WeatherExtension/Services/OpenMeteoService.cs index f86aacf..bb57877 100644 --- a/WeatherExtension/Services/OpenMeteoService.cs +++ b/WeatherExtension/Services/OpenMeteoService.cs @@ -13,6 +13,7 @@ public sealed partial class OpenMeteoService : IWeatherService, IDisposable { private readonly HttpClient _httpClient; private const string BaseUrl = "https://api.open-meteo.com/v1/forecast"; + private const string ConnectivityProbeUrl = "https://connectivitycheck.gstatic.com/generate_204"; private const int CacheExpirationMinutes = 15; private WeatherData? _cachedWeather; @@ -36,6 +37,15 @@ public OpenMeteoService() _httpClient.DefaultRequestHeaders.Add("User-Agent", "PowerToys-CmdPal-Weather/1.0"); } + internal OpenMeteoService(HttpMessageHandler handler) + { + _httpClient = new HttpClient(handler) + { + Timeout = TimeSpan.FromSeconds(10), + }; + _httpClient.DefaultRequestHeaders.Add("User-Agent", "PowerToys-CmdPal-Weather/1.0"); + } + public async Task GetCurrentWeatherAsync( double latitude, double longitude, @@ -64,6 +74,7 @@ public OpenMeteoService() WeatherLogger.LogToHost( MessageState.Error, $"Weather API returned status {response.StatusCode}"); + await ProbeConnectivityAsync(Resources.connectivity_endpoint_current_weather, ct).ConfigureAwait(false); return null; } @@ -86,11 +97,16 @@ public OpenMeteoService() return weatherData; } + catch (OperationCanceledException) + { + throw; + } catch (Exception ex) { WeatherLogger.LogToHost( MessageState.Error, $"Weather fetch error: {ex.Message}"); + await ProbeConnectivityAsync(Resources.connectivity_endpoint_current_weather, ct).ConfigureAwait(false); return null; } } @@ -122,6 +138,7 @@ public OpenMeteoService() WeatherLogger.LogToHost( MessageState.Error, $"Forecast API returned status {response.StatusCode}"); + await ProbeConnectivityAsync(Resources.connectivity_endpoint_forecast, ct).ConfigureAwait(false); return null; } @@ -144,11 +161,16 @@ public OpenMeteoService() return forecastData; } + catch (OperationCanceledException) + { + throw; + } catch (Exception ex) { WeatherLogger.LogToHost( MessageState.Error, $"Forecast fetch error: {ex.Message}"); + await ProbeConnectivityAsync(Resources.connectivity_endpoint_forecast, ct).ConfigureAwait(false); return null; } } @@ -181,6 +203,7 @@ public OpenMeteoService() WeatherLogger.LogToHost( MessageState.Error, $"Hourly forecast API returned status {response.StatusCode}"); + await ProbeConnectivityAsync(Resources.connectivity_endpoint_hourly_forecast, ct).ConfigureAwait(false); return null; } @@ -203,15 +226,61 @@ public OpenMeteoService() return hourlyData; } + catch (OperationCanceledException) + { + throw; + } catch (Exception ex) { WeatherLogger.LogToHost( MessageState.Error, $"Hourly forecast fetch error: {ex.Message}"); + await ProbeConnectivityAsync(Resources.connectivity_endpoint_hourly_forecast, ct).ConfigureAwait(false); return null; } } + private async Task ProbeConnectivityAsync(string failedEndpoint, CancellationToken ct) + { + try + { + using var probeRequest = new HttpRequestMessage(HttpMethod.Head, ConnectivityProbeUrl); + probeRequest.Headers.Add("User-Agent", "PowerToys-CmdPal-Weather/1.0"); + + using var probeCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + probeCts.CancelAfter(TimeSpan.FromSeconds(2)); + + var probeResponse = await _httpClient.SendAsync(probeRequest, probeCts.Token).ConfigureAwait(false); + + if (probeResponse.StatusCode == System.Net.HttpStatusCode.NoContent) + { + // Got expected 204 — we have internet, weather API is specifically unreachable + WeatherLogger.LogToHost( + MessageState.Error, + $"{Resources.connectivity_api_blocked} ({failedEndpoint})"); + } + else + { + // Non-204 response (e.g., captive portal redirect) — treat as no internet + WeatherLogger.LogToHost( + MessageState.Warning, + Resources.connectivity_no_internet); + } + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + // Caller cancelled — don't log, just return + return; + } + catch + { + // Probe failed (timeout or network error) — no internet connection + WeatherLogger.LogToHost( + MessageState.Warning, + Resources.connectivity_no_internet); + } + } + public void Dispose() { _httpClient?.Dispose();