Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
141 changes: 141 additions & 0 deletions WeatherExtension.Tests/ConnectivityProbeTests.cs
Original file line number Diff line number Diff line change
@@ -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;
Comment on lines +4 to +6
Comment thread
michaeljolley marked this conversation as resolved.
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");
}
}

45 changes: 45 additions & 0 deletions WeatherExtension/Properties/Resources.Designer.cs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

15 changes: 15 additions & 0 deletions WeatherExtension/Properties/Resources.resx
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,21 @@ If you continue to have issues, please [file a bug](https://github.com/michaeljo
<data name="twelve_hours" xml:space="preserve">
<value>12 hours</value>
</data>
<data name="connectivity_no_internet" xml:space="preserve">
<value>No internet connection</value>
</data>
<data name="connectivity_api_blocked" xml:space="preserve">
<value>Weather API unreachable — may be blocked in your region</value>
</data>
<data name="connectivity_endpoint_current_weather" xml:space="preserve">
<value>current weather</value>
</data>
<data name="connectivity_endpoint_forecast" xml:space="preserve">
<value>forecast</value>
</data>
<data name="connectivity_endpoint_hourly_forecast" xml:space="preserve">
<value>hourly forecast</value>
Comment thread
michaeljolley marked this conversation as resolved.
</data>
<data name="favorites_section_title" xml:space="preserve">
<value>Favorites</value>
</data>
Expand Down
69 changes: 69 additions & 0 deletions WeatherExtension/Services/OpenMeteoService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<WeatherData?> GetCurrentWeatherAsync(
double latitude,
double longitude,
Expand Down Expand Up @@ -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;
}

Expand All @@ -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;
}
}
Expand Down Expand Up @@ -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;
}

Expand All @@ -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;
}
}
Expand Down Expand Up @@ -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;
}

Expand All @@ -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");
Comment thread
michaeljolley marked this conversation as resolved.

using var probeCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
probeCts.CancelAfter(TimeSpan.FromSeconds(2));

var probeResponse = await _httpClient.SendAsync(probeRequest, probeCts.Token).ConfigureAwait(false);
Comment thread
michaeljolley marked this conversation as resolved.
Comment thread
michaeljolley marked this conversation as resolved.

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})");
Comment thread
michaeljolley marked this conversation as resolved.
Comment thread
michaeljolley marked this conversation as resolved.
}
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);
}
Comment thread
michaeljolley marked this conversation as resolved.
}

public void Dispose()
{
_httpClient?.Dispose();
Expand Down
Loading