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

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'),
);
});
});
}