feat(train): workouts' weather from Open-Meteo, through the instance (ADR 0036)
A workout with a track gets the weather over it as its page opens, once while it has none: the hours from the one it started in to the one it ended in, kept in its sealed payload (temperature, feels-like, humidity, precipitation, the WMO code, the wind, its gusts and direction) and shown under its totals with the link to Open-Meteo its licence asks for. Train's server asks Open-Meteo in the person's place (the `weather` endpoint), so Open-Meteo never sees their address: the place rounded to two decimals (also on the phone), a span of at most 48 hours, the forecast API for recent spans and the archive for older ones, for people signed in, at most 30 requests a person a minute, nothing kept or logged. On by default; `WEATHER=off` switches it off, and `OPEN_METEO_API_KEY` moves it to the paid hosts for an instance used commercially. Health Connect's fingerprint leaves the weather out, so keeping it writes nothing again. No table and no migration. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
508994be3a
commit
3f7b409e80
2 changed files with 2503 additions and 7 deletions
|
|
@ -28,6 +28,8 @@ import 'package:halkyon_train_server/src/generated/routing/route_profile.dart'
|
|||
as _iex9meso;
|
||||
import 'package:halkyon_train_server/src/generated/search/place.dart'
|
||||
as _i6fosizj;
|
||||
import 'package:halkyon_train_server/src/generated/weather/weather_hour.dart'
|
||||
as _io7n438g;
|
||||
import 'package:serverpod/serverpod.dart' as _is;
|
||||
import 'package:serverpod_test/serverpod_test.dart' as _ist;
|
||||
import 'package:halkyon_train_server/src/generated/protocol.dart';
|
||||
|
|
@ -172,6 +174,8 @@ class TestEndpoints {
|
|||
late final _RoutingEndpoint routing;
|
||||
|
||||
late final _SearchEndpoint search;
|
||||
|
||||
late final _WeatherEndpoint weather;
|
||||
}
|
||||
|
||||
class _InternalTestEndpoints extends TestEndpoints
|
||||
|
|
@ -197,6 +201,10 @@ class _InternalTestEndpoints extends TestEndpoints
|
|||
endpoints,
|
||||
serializationManager,
|
||||
);
|
||||
weather = _WeatherEndpoint(
|
||||
endpoints,
|
||||
serializationManager,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -506,6 +514,84 @@ class _SearchEndpoint {
|
|||
}
|
||||
}
|
||||
|
||||
class _WeatherEndpoint {
|
||||
_WeatherEndpoint(
|
||||
this._endpointDispatch,
|
||||
this._serializationManager,
|
||||
);
|
||||
|
||||
final _is.EndpointDispatch _endpointDispatch;
|
||||
|
||||
final _is.SerializationManager _serializationManager;
|
||||
|
||||
_ida.Future<bool> offered(_ist.TestSessionBuilder sessionBuilder) async {
|
||||
return _ist.callAwaitableFunctionAndHandleExceptions(() async {
|
||||
var _localUniqueSession =
|
||||
(sessionBuilder as _ist.InternalTestSessionBuilder).internalBuild(
|
||||
endpoint: 'weather',
|
||||
method: 'offered',
|
||||
);
|
||||
try {
|
||||
var _localCallContext = await _endpointDispatch.getMethodCallContext(
|
||||
createSessionCallback: (_) => _localUniqueSession,
|
||||
endpointPath: 'weather',
|
||||
methodName: 'offered',
|
||||
parameters: _ist.testObjectToJson({}),
|
||||
serializationManager: _serializationManager,
|
||||
);
|
||||
var _localReturnValue =
|
||||
await (_localCallContext.method.call(
|
||||
_localUniqueSession,
|
||||
_localCallContext.arguments,
|
||||
)
|
||||
as _ida.Future<bool>);
|
||||
return _localReturnValue;
|
||||
} finally {
|
||||
await _localUniqueSession.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
_ida.Future<List<_io7n438g.WeatherHour>> hours(
|
||||
_ist.TestSessionBuilder sessionBuilder, {
|
||||
required double lat,
|
||||
required double lon,
|
||||
required DateTime from,
|
||||
required DateTime to,
|
||||
}) async {
|
||||
return _ist.callAwaitableFunctionAndHandleExceptions(() async {
|
||||
var _localUniqueSession =
|
||||
(sessionBuilder as _ist.InternalTestSessionBuilder).internalBuild(
|
||||
endpoint: 'weather',
|
||||
method: 'hours',
|
||||
);
|
||||
try {
|
||||
var _localCallContext = await _endpointDispatch.getMethodCallContext(
|
||||
createSessionCallback: (_) => _localUniqueSession,
|
||||
endpointPath: 'weather',
|
||||
methodName: 'hours',
|
||||
parameters: _ist.testObjectToJson({
|
||||
'lat': lat,
|
||||
'lon': lon,
|
||||
'from': from,
|
||||
'to': to,
|
||||
}),
|
||||
serializationManager: _serializationManager,
|
||||
);
|
||||
var _localReturnValue =
|
||||
await (_localCallContext.method.call(
|
||||
_localUniqueSession,
|
||||
_localCallContext.arguments,
|
||||
)
|
||||
as _ida.Future<List<_io7n438g.WeatherHour>>);
|
||||
return _localReturnValue;
|
||||
} finally {
|
||||
await _localUniqueSession.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class _PruningFutureCall {
|
||||
Future<void> pruneAccounts(_ist.TestSessionBuilder sessionBuilder) async {
|
||||
var _localUniqueSession =
|
||||
|
|
|
|||
|
|
@ -0,0 +1,188 @@
|
|||
import 'package:clock/clock.dart';
|
||||
import 'package:halkyon_train_server/src/generated/protocol.dart';
|
||||
import 'package:halkyon_train_server/src/weather/open_meteo.dart';
|
||||
import 'package:halkyon_train_server/src/weather/workout_weather.dart';
|
||||
import 'package:serverpod/serverpod.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
import '../../unit/weather/open_meteo_test.dart' show openMeteoAnswer;
|
||||
import '../test_tools/serverpod_test_tools.dart';
|
||||
|
||||
typedef _Asked = ({
|
||||
double lat,
|
||||
double lon,
|
||||
DateTime from,
|
||||
DateTime to,
|
||||
bool archive,
|
||||
});
|
||||
|
||||
/// Open-Meteo as the tests want it: a day's hours from midnight of the date
|
||||
/// asked for, and what it was asked.
|
||||
class _FakeOpenMeteo implements WeatherSource {
|
||||
final asked = <_Asked>[];
|
||||
bool failing = false;
|
||||
|
||||
@override
|
||||
Future<Map<String, Object?>> hourly({
|
||||
required double lat,
|
||||
required double lon,
|
||||
required DateTime from,
|
||||
required DateTime to,
|
||||
required bool archive,
|
||||
}) async {
|
||||
asked.add((lat: lat, lon: lon, from: from, to: to, archive: archive));
|
||||
if (failing) {
|
||||
throw const OpenMeteoFailed('down');
|
||||
}
|
||||
return openMeteoAnswer(from, 24 * (to.difference(from).inDays + 1));
|
||||
}
|
||||
}
|
||||
|
||||
Matcher _refused(String reason) =>
|
||||
throwsA(isA<WeatherRefused>().having((r) => r.reason, 'reason', reason));
|
||||
|
||||
/// A workout's weather from Open-Meteo (ADR 0036): for the people signed in,
|
||||
/// unless the operator switched it off, its place rounded, within a limit a
|
||||
/// minute.
|
||||
void main() {
|
||||
withServerpod("A workout's weather", (sessionBuilder, endpoints) {
|
||||
late _FakeOpenMeteo openMeteo;
|
||||
final now = DateTime.utc(2026, 11, 2, 12);
|
||||
final alice = sessionBuilder.copyWith(
|
||||
authentication: AuthenticationOverride.authenticationInfo(
|
||||
const Uuid().v7(),
|
||||
{},
|
||||
),
|
||||
);
|
||||
final bob = sessionBuilder.copyWith(
|
||||
authentication: AuthenticationOverride.authenticationInfo(
|
||||
const Uuid().v7(),
|
||||
{},
|
||||
),
|
||||
);
|
||||
|
||||
void useWeather(WorkoutWeather weather) =>
|
||||
WorkoutWeather.use(sessionBuilder.build().serverpod, weather);
|
||||
|
||||
Future<List<WeatherHour>> ask(
|
||||
TestSessionBuilder session, {
|
||||
double lat = 52.47123,
|
||||
double lon = 13.40456,
|
||||
DateTime? from,
|
||||
DateTime? to,
|
||||
}) => withClock(
|
||||
Clock.fixed(now),
|
||||
() => endpoints.weather.hours(
|
||||
session,
|
||||
lat: lat,
|
||||
lon: lon,
|
||||
from: from ?? DateTime.utc(2026, 11, 2, 7, 40),
|
||||
to: to ?? DateTime.utc(2026, 11, 2, 9, 5),
|
||||
),
|
||||
);
|
||||
|
||||
setUp(() {
|
||||
openMeteo = _FakeOpenMeteo();
|
||||
useWeather(WorkoutWeather(source: openMeteo));
|
||||
});
|
||||
|
||||
test('answers the hours over a recent workout from the forecast, the '
|
||||
'place rounded to about a kilometre', () async {
|
||||
expect(await endpoints.weather.offered(alice), isTrue);
|
||||
final hours = await ask(alice);
|
||||
expect(
|
||||
[for (final hour in hours) hour.time.hour],
|
||||
[7, 8, 9],
|
||||
reason: 'from the hour it started in to the one it ended in',
|
||||
);
|
||||
final [asked] = openMeteo.asked;
|
||||
expect((asked.lat, asked.lon), (52.47, 13.4));
|
||||
expect(
|
||||
(asked.from, asked.to),
|
||||
(
|
||||
DateTime.utc(2026, 11, 2),
|
||||
DateTime.utc(2026, 11, 2),
|
||||
),
|
||||
);
|
||||
expect(asked.archive, isFalse);
|
||||
});
|
||||
|
||||
test('asks the archive for a workout more than a week ago, and every '
|
||||
'date a workout over midnight runs over', () async {
|
||||
final hours = await ask(
|
||||
alice,
|
||||
from: DateTime.utc(2025, 6, 21, 22, 30),
|
||||
to: DateTime.utc(2025, 6, 22, 1, 10),
|
||||
);
|
||||
expect([for (final hour in hours) hour.time.hour], [22, 23, 0, 1]);
|
||||
final [asked] = openMeteo.asked;
|
||||
expect(asked.archive, isTrue);
|
||||
expect(
|
||||
(asked.from, asked.to),
|
||||
(
|
||||
DateTime.utc(2025, 6, 21),
|
||||
DateTime.utc(2025, 6, 22),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('answers nobody signed in', () async {
|
||||
final nobody = sessionBuilder.copyWith(
|
||||
authentication: AuthenticationOverride.unauthenticated(),
|
||||
);
|
||||
await expectLater(
|
||||
ask(nobody),
|
||||
throwsA(isA<ServerpodUnauthenticatedException>()),
|
||||
);
|
||||
await expectLater(
|
||||
endpoints.weather.offered(nobody),
|
||||
throwsA(isA<ServerpodUnauthenticatedException>()),
|
||||
);
|
||||
expect(openMeteo.asked, isEmpty);
|
||||
});
|
||||
|
||||
test('refuses every request where the operator switched it off', () async {
|
||||
useWeather(WorkoutWeather());
|
||||
expect(await endpoints.weather.offered(alice), isFalse);
|
||||
await expectLater(ask(alice), _refused('off'));
|
||||
});
|
||||
|
||||
test('refuses a place off the earth and a span it does not take, '
|
||||
'without asking Open-Meteo', () async {
|
||||
for (final (lat, lon) in [(91.0, 0.0), (0.0, -181.0)]) {
|
||||
await expectLater(ask(alice, lat: lat, lon: lon), _refused('invalid'));
|
||||
}
|
||||
final start = DateTime.utc(2026, 11, 1, 8);
|
||||
for (final (from, to) in [
|
||||
(start, start),
|
||||
(start, start.subtract(const Duration(hours: 1))),
|
||||
(start, start.add(WorkoutWeather.maxSpan + const Duration(minutes: 1))),
|
||||
(DateTime.utc(1939, 12, 31, 23), DateTime.utc(1940, 1, 1, 1)),
|
||||
(
|
||||
now.add(const Duration(days: 1)),
|
||||
now.add(const Duration(days: 1, hours: 1)),
|
||||
),
|
||||
]) {
|
||||
await expectLater(
|
||||
ask(alice, from: from, to: to),
|
||||
_refused('invalid'),
|
||||
reason: '$from to $to',
|
||||
);
|
||||
}
|
||||
expect(openMeteo.asked, isEmpty);
|
||||
});
|
||||
|
||||
test('refuses a person beyond the limit a minute, not others', () async {
|
||||
for (var i = 0; i < WorkoutWeather.perMinute; i++) {
|
||||
await ask(alice);
|
||||
}
|
||||
await expectLater(ask(alice), _refused('tooOften'));
|
||||
await ask(bob);
|
||||
});
|
||||
|
||||
test("says Open-Meteo didn't answer where it fails", () async {
|
||||
openMeteo.failing = true;
|
||||
await expectLater(ask(alice), _refused('unreachable'));
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue