Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
124 changes: 124 additions & 0 deletions WeatherExtension.Tests/ConnectivityProbeTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
// 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.Net;
Comment on lines +4 to +6
Comment thread
michaeljolley marked this conversation as resolved.
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()

Check failure on line 15 in WeatherExtension.Tests/ConnectivityProbeTests.cs

View workflow job for this annotation

GitHub Actions / build-and-test

The type or namespace name 'Task' could not be found (are you missing a using directive or an assembly reference?)

Check failure on line 15 in WeatherExtension.Tests/ConnectivityProbeTests.cs

View workflow job for this annotation

GitHub Actions / build-and-test

The type or namespace name 'Task' could not be found (are you missing a using directive or an assembly reference?)
{
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()

Check failure on line 33 in WeatherExtension.Tests/ConnectivityProbeTests.cs

View workflow job for this annotation

GitHub Actions / build-and-test

The type or namespace name 'Task' could not be found (are you missing a using directive or an assembly reference?)

Check failure on line 33 in WeatherExtension.Tests/ConnectivityProbeTests.cs

View workflow job for this annotation

GitHub Actions / build-and-test

The type or namespace name 'Task' could not be found (are you missing a using directive or an assembly reference?)
{
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()

Check failure on line 51 in WeatherExtension.Tests/ConnectivityProbeTests.cs

View workflow job for this annotation

GitHub Actions / build-and-test

The type or namespace name 'Task' could not be found (are you missing a using directive or an assembly reference?)

Check failure on line 51 in WeatherExtension.Tests/ConnectivityProbeTests.cs

View workflow job for this annotation

GitHub Actions / build-and-test

The type or namespace name 'Task' could not be found (are you missing a using directive or an assembly reference?)
{
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()

Check failure on line 68 in WeatherExtension.Tests/ConnectivityProbeTests.cs

View workflow job for this annotation

GitHub Actions / build-and-test

The type or namespace name 'Task' could not be found (are you missing a using directive or an assembly reference?)

Check failure on line 68 in WeatherExtension.Tests/ConnectivityProbeTests.cs

View workflow job for this annotation

GitHub Actions / build-and-test

The type or namespace name 'Task' could not be found (are you missing a using directive or an assembly reference?)
{
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()

Check failure on line 85 in WeatherExtension.Tests/ConnectivityProbeTests.cs

View workflow job for this annotation

GitHub Actions / build-and-test

The type or namespace name 'Task' could not be found (are you missing a using directive or an assembly reference?)
{
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()

Check failure on line 105 in WeatherExtension.Tests/ConnectivityProbeTests.cs

View workflow job for this annotation

GitHub Actions / build-and-test

The type or namespace name 'Task' could not be found (are you missing a using directive or an assembly reference?)
{
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);
await Assert.ThrowsExceptionAsync<OperationCanceledException>(
() => service.GetCurrentWeatherAsync(52.52, 13.41, ct: cts.Token));

// 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 @@ -256,4 +256,19 @@
<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>
</root>
54 changes: 54 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,46 @@ 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.

// Probe succeeded — we have internet, so the weather API is specifically unreachable
WeatherLogger.LogToHost(
MessageState.Error,
$"{Resources.connectivity_api_blocked} ({failedEndpoint})");
Comment thread
michaeljolley marked this conversation as resolved.
Outdated
}
catch
{
// Probe failed — 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