feat(todo): the server finds places through the instance's Photon (ADR 0033, D32)

Todo's locations search through the person's own instance, as Train's
planner does, not a public service: `SearchEndpoint` on `PlaceSearch`, for
those signed in, 60 searches a minute, nothing kept or logged. Its `Place`
carries the address's parts and the object in OpenStreetMap, and a shop's
car park or charger is left out, which came first for `Lindau EDEKA`.

`compose.todo-search.yaml` puts Todo's server on compose.search.yaml's
Photon. A file of its own: without it Todo starts as it did, and offers no
search.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-11-05 14:58:38 +01:00
commit c9d3eb4dd1
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
3 changed files with 1880 additions and 12 deletions

View file

@ -0,0 +1,190 @@
import 'package:clock/clock.dart';
import 'package:halkyon_todo_server/src/generated/protocol.dart';
import 'package:halkyon_todo_server/src/search/photon.dart';
import 'package:halkyon_todo_server/src/search/place_search.dart';
import 'package:serverpod/serverpod.dart';
import 'package:test/test.dart';
import '../../unit/search/photon_test.dart' show photonAnswer;
import '../test_tools/serverpod_test_tools.dart';
/// Photon as the tests want it: what it answers, and what it was asked.
class _FakePhoton implements PlaceFinder {
final asked = <({String query, PlacePoint? near, String? language})>[];
final limits = <int>[];
bool failing = false;
/// What Photon finds.
List<Map<String, Object?>> answer = const [
{
'lat': 52.47,
'lon': 13.4,
'name': 'Tempelhofer Feld',
'type': 'other',
'city': 'Berlin',
'country': 'Germany',
},
];
@override
Future<Map<String, Object?>> search(
String query, {
PlacePoint? near,
String? language,
required int limit,
}) async {
asked.add((query: query, near: near, language: language));
limits.add(limit);
if (failing) {
throw const PhotonFailed('down');
}
return photonAnswer(answer);
}
}
Matcher _refused(String reason) =>
throwsA(isA<SearchRefused>().having((r) => r.reason, 'reason', reason));
/// Places found by the instance's Photon (ADR 0033, D32): for the people
/// signed in, where the instance has one, within a limit a minute.
void main() {
withServerpod('Searching for places', (sessionBuilder, endpoints) {
late _FakePhoton photon;
final alice = sessionBuilder.copyWith(
authentication: AuthenticationOverride.authenticationInfo(
const Uuid().v7(),
{},
),
);
final bob = sessionBuilder.copyWith(
authentication: AuthenticationOverride.authenticationInfo(
const Uuid().v7(),
{},
),
);
void useSearch(PlaceSearch search) =>
PlaceSearch.use(sessionBuilder.build().serverpod, search);
setUp(() {
photon = _FakePhoton();
useSearch(PlaceSearch(finder: photon));
});
test('finds places for someone signed in, nearest the map first, in '
'their language', () async {
expect(await endpoints.search.offered(alice), isTrue);
final places = await endpoints.search.places(
alice,
' Tempelhof ',
near: PlacePoint(lat: 52.5, lon: 13.4),
language: 'de',
);
expect(places.single.name, 'Tempelhofer Feld');
expect(places.single.detail, 'Berlin, Germany');
final asked = photon.asked.single;
expect(asked.query, 'Tempelhof');
expect(asked.near?.lat, 52.5);
expect(asked.language, 'de');
});
test('a language Photon has no names in asks for none', () async {
await endpoints.search.places(alice, 'Paris', language: 'fr');
expect(photon.asked.single.language, isNull);
});
test('searches nothing for nobody signed in', () async {
final nobody = sessionBuilder.copyWith(
authentication: AuthenticationOverride.unauthenticated(),
);
await expectLater(
endpoints.search.places(nobody, 'Berlin'),
throwsA(isA<ServerpodUnauthenticatedException>()),
);
await expectLater(
endpoints.search.offered(nobody),
throwsA(isA<ServerpodUnauthenticatedException>()),
);
expect(photon.asked, isEmpty);
});
test('refuses every search where the instance has no Photon', () async {
useSearch(PlaceSearch());
expect(await endpoints.search.offered(alice), isFalse);
await expectLater(
endpoints.search.places(alice, 'Berlin'),
_refused('off'),
);
});
test('refuses a query too short or too long, or a point off the earth, '
'asking Photon nothing', () async {
for (final query in ['', ' x ', 'x' * (PlaceSearch.maxQuery + 1)]) {
await expectLater(
endpoints.search.places(alice, query),
_refused('invalid'),
reason: query,
);
}
await expectLater(
endpoints.search.places(
alice,
'Berlin',
near: PlacePoint(lat: 91, lon: 0),
),
_refused('invalid'),
);
expect(photon.asked, isEmpty);
});
test('refuses a person beyond the limit a minute, not others, and '
'takes them again a minute later', () async {
final start = DateTime.utc(2026, 10, 29, 12);
await withClock(Clock.fixed(start), () async {
for (var i = 0; i < PlaceSearch.perMinute; i++) {
await endpoints.search.places(alice, 'Berlin');
}
await expectLater(
endpoints.search.places(alice, 'Berlin'),
_refused('tooOften'),
);
await endpoints.search.places(bob, 'Berlin');
});
await withClock(
Clock.fixed(start.add(const Duration(minutes: 1))),
() => endpoints.search.places(alice, 'Berlin'),
);
});
test('asks Photon for more than it lists, as what is no place to go is '
'left out, and lists no more than the limit', () async {
photon.answer = [
for (var i = 0; i < 4; i++)
{
'lat': 1,
'lon': 2,
'name': 'EDEKA',
'type': 'other',
'osm_value': 'parking',
},
for (var i = 0; i < PlaceSearch.limit + 2; i++)
{'lat': 1, 'lon': 2, 'name': 'EDEKA $i', 'type': 'house'},
];
final places = await endpoints.search.places(alice, 'EDEKA');
expect(photon.limits.single, greaterThan(PlaceSearch.limit));
expect(places, hasLength(PlaceSearch.limit));
expect(places.first.name, 'EDEKA 0');
});
test("says Photon didn't answer where it fails", () async {
photon.failing = true;
await expectLater(
endpoints.search.places(alice, 'Berlin'),
_refused('unreachable'),
);
});
});
}

View file

@ -11,9 +11,14 @@
// ignore_for_file: no_leading_underscores_for_local_identifiers
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'dart:async' as _ida;
import 'dart:io' as _idi;
import 'package:halkyon_todo_server/src/generated/future_calls.dart'
as _i772x17y;
import 'package:halkyon_todo_server/src/generated/search/place.dart'
as _ih6de0l6;
import 'package:halkyon_todo_server/src/generated/search/place_point.dart'
as _iywv9say;
import 'package:serverpod/serverpod.dart' as _is;
import 'package:serverpod_test/serverpod_test.dart' as _ist;
import 'package:halkyon_todo_server/src/generated/protocol.dart';
@ -150,6 +155,8 @@ void withServerpod(
class TestEndpoints {
late final futureCalls = _FutureCalls();
late final _SearchEndpoint search;
}
class _InternalTestEndpoints extends TestEndpoints
@ -158,13 +165,94 @@ class _InternalTestEndpoints extends TestEndpoints
void initialize(
_is.SerializationManager serializationManager,
_is.EndpointDispatch endpoints,
) {}
) {
search = _SearchEndpoint(
endpoints,
serializationManager,
);
}
}
class _FutureCalls {
late final pruning = _PruningFutureCall();
}
class _SearchEndpoint {
_SearchEndpoint(
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: 'search',
method: 'offered',
);
try {
var _localCallContext = await _endpointDispatch.getMethodCallContext(
createSessionCallback: (_) => _localUniqueSession,
endpointPath: 'search',
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<_ih6de0l6.Place>> places(
_ist.TestSessionBuilder sessionBuilder,
String query, {
_iywv9say.PlacePoint? near,
String? language,
}) async {
return _ist.callAwaitableFunctionAndHandleExceptions(() async {
var _localUniqueSession =
(sessionBuilder as _ist.InternalTestSessionBuilder).internalBuild(
endpoint: 'search',
method: 'places',
);
try {
var _localCallContext = await _endpointDispatch.getMethodCallContext(
createSessionCallback: (_) => _localUniqueSession,
endpointPath: 'search',
methodName: 'places',
parameters: _ist.testObjectToJson({
'query': query,
'near': near,
'language': language,
}),
serializationManager: _serializationManager,
);
var _localReturnValue =
await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
)
as _ida.Future<List<_ih6de0l6.Place>>);
return _localReturnValue;
} finally {
await _localUniqueSession.close();
}
});
}
}
class _PruningFutureCall {
Future<void> pruneAccounts(_ist.TestSessionBuilder sessionBuilder) async {
var _localUniqueSession =

View file

@ -0,0 +1,246 @@
import 'dart:convert';
import 'dart:io';
import 'package:halkyon_todo_server/src/generated/protocol.dart';
import 'package:halkyon_todo_server/src/search/photon.dart';
import 'package:test/test.dart';
/// Photon's answer as its API documents it (`docs/api-v1.md` of v1.3.0,
/// read 29 Oct 2026): GeoJSON features, points `[lon, lat]`, their
/// properties GeocodeJSON's (`name`, `street`, `housenumber`, `city`,
/// `district`, `country`, `type`, …).
Map<String, Object?> photonAnswer(List<Map<String, Object?>> places) => {
'type': 'FeatureCollection',
'features': [
for (final place in places)
{
'type': 'Feature',
'geometry': {
'type': 'Point',
'coordinates': [place['lon'], place['lat']],
},
'properties': {
for (final MapEntry(:key, :value) in place.entries)
if (key != 'lat' && key != 'lon') key: value,
},
},
],
};
void main() {
test('a place is its name, where it is and its kind', () {
final places = placesOf(
photonAnswer([
{
'lat': 52.5145,
'lon': 13.3501,
'name': 'Tiergarten',
'osm_key': 'leisure',
'osm_value': 'park',
'type': 'other',
'district': 'Tiergarten',
'city': 'Berlin',
'country': 'Germany',
},
{
'lat': 52.52,
'lon': 13.405,
'name': 'Berlin',
'type': 'city',
'city': 'Berlin',
'country': 'Germany',
},
]),
);
// Serverpod's models have no equality of their own.
expect(
[for (final place in places) place.toJson()],
[
Place(
name: 'Tiergarten',
detail: 'Berlin, Germany',
kind: 'other',
osmValue: 'park',
district: 'Tiergarten',
city: 'Berlin',
lat: 52.5145,
lon: 13.3501,
).toJson(),
Place(
name: 'Berlin',
detail: 'Germany',
kind: 'city',
city: 'Berlin',
lat: 52.52,
lon: 13.405,
).toJson(),
],
);
});
test('an address without a name is its street and number', () {
final [place] = placesOf(
photonAnswer([
{
'lat': 48.137,
'lon': 11.575,
'type': 'house',
'street': 'Marienplatz',
'housenumber': '8',
'city': 'München',
'country': 'Deutschland',
},
]),
);
expect(place.name, 'Marienplatz 8');
expect(place.detail, 'München, Deutschland');
});
test('features without a point or a name, and answers without features, '
'are no places', () {
expect(
placesOf({
'features': [
{
'geometry': {
'type': 'LineString',
'coordinates': [
[1, 2],
[3, 4],
],
},
'properties': {'name': 'A line'},
},
{
'geometry': {
'type': 'Point',
'coordinates': [1, 2],
},
'properties': {'type': 'house'},
},
'not a feature',
],
}),
isEmpty,
);
expect(placesOf(const {}), isEmpty);
});
test('a place keeps its address and its object in OpenStreetMap, which '
"tell two of a town's supermarkets apart", () {
final [place] = placesOf(
photonAnswer([
{
'lat': 47.5606,
'lon': 9.7011,
'name': 'EDEKA',
'type': 'house',
'osm_type': 'W',
'osm_id': 79817244,
'osm_key': 'shop',
'osm_value': 'supermarket',
'street': 'Ludwig-Kick-Straße',
'housenumber': '1',
'district': 'Aeschach',
'city': 'Lindau (Bodensee)',
'country': 'Deutschland',
},
]),
);
expect(place.osmValue, 'supermarket');
expect(place.osmType, 'W');
expect(place.osmId, 79817244);
expect(place.street, 'Ludwig-Kick-Straße');
expect(place.housenumber, '1');
expect(place.district, 'Aeschach');
expect(place.city, 'Lindau (Bodensee)');
expect(
place.detail,
'Ludwig-Kick-Straße 1, Lindau (Bodensee), Deutschland',
);
});
test('a place without what OpenStreetMap knows of it has none of it', () {
final [place] = placesOf(
photonAnswer([
{'lat': 1, 'lon': 2, 'name': 'Somewhere', 'type': 'other'},
]),
);
expect(place.osmValue, isNull);
expect(place.osmType, isNull);
expect(place.osmId, isNull);
expect(place.street, isNull);
expect(place.district, isNull);
});
test('a car park or a charger named after a shop is no place to go: a '
'search for EDEKA finds the shop', () {
final places = placesOf(
photonAnswer([
for (final value in [
'parking',
'parking_entrance',
'parking_space',
'charging_station',
])
{
'lat': 47.56,
'lon': 9.70,
'name': 'EDEKA',
'type': 'other',
'osm_value': value,
},
{
'lat': 47.5606,
'lon': 9.7011,
'name': 'EDEKA',
'type': 'house',
'osm_value': 'supermarket',
},
]),
);
expect([for (final place in places) place.osmValue], ['supermarket']);
});
test('HttpPhoton asks /api with the query, the point, the language and '
'the limit, and fails on what is no answer', () async {
final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
final asked = <Uri>[];
var status = HttpStatus.ok;
server.listen((request) async {
asked.add(request.uri);
request.response
..statusCode = status
..write(jsonEncode(photonAnswer(const [])));
await request.response.close();
});
addTearDown(server.close);
final photon = HttpPhoton(Uri.parse('http://127.0.0.1:${server.port}/'));
await photon.search(
'Tempelhofer Feld',
near: PlacePoint(lat: 52.47, lon: 13.4),
language: 'de',
limit: 8,
);
expect(asked.single.path, '/api');
expect(asked.single.queryParameters, {
'q': 'Tempelhofer Feld',
'limit': '8',
'lat': '52.47',
'lon': '13.4',
'lang': 'de',
});
status = HttpStatus.badRequest;
await expectLater(
photon.search('x', limit: 8),
throwsA(isA<PhotonFailed>()),
);
});
}