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
21 changed files with 1880 additions and 12 deletions

File diff suppressed because one or more lines are too long

View file

@ -13,6 +13,10 @@
import 'dart:async' as _ida;
import 'package:halkyon_base_client/halkyon_base_client.dart' as _ihk4p5t7;
import 'package:halkyon_oidc_client/halkyon_oidc_client.dart' as _ibkpwzgl;
import 'package:halkyon_todo_client/src/protocol/search/place.dart'
as _i15767wh;
import 'package:halkyon_todo_client/src/protocol/search/place_point.dart'
as _icyq0v3n;
import 'package:http/http.dart' as _i85jenna;
import 'package:serverpod_auth_core_client/serverpod_auth_core_client.dart'
as _iacc;
@ -26,6 +30,44 @@ import 'protocol.dart' as _il2as5qe;
import 'sync_tables.dart' as _ii3f3u05;
import 'package:halkyon_todo_client/migrations/migration_registry.dart';
/// Places found by the instance's Photon (ADR 0033, D32), for those signed
/// in: Photon has no sign-in of its own, so the server alone reaches it.
/// Neither the query nor the places are kept or logged, and a task's location
/// stays in its sealed payload. Refusals are `SearchRefused`.
/// {@category Endpoint}
class EndpointSearch extends _isc.EndpointRef {
EndpointSearch(_isc.EndpointCaller caller) : super(caller);
@override
String get name => 'search';
/// Whether the instance searches for places.
_ida.Future<bool> offered() => caller.callServerEndpoint<bool>(
'search',
'offered',
{},
);
/// The places [query] finds, nearest [near] first (the map's centre, or
/// the person), named in [language] (`en`, `de`; else each its own name).
/// Refused where the instance has no Photon or it fails, for a query of
/// fewer than `PlaceSearch.minQuery` or more than `PlaceSearch.maxQuery`
/// characters, and beyond `PlaceSearch.perMinute` searches a minute.
_ida.Future<List<_i15767wh.Place>> places(
String query, {
_icyq0v3n.PlacePoint? near,
String? language,
}) => caller.callServerEndpoint<List<_i15767wh.Place>>(
'search',
'places',
{
'query': query,
'near': near,
'language': language,
},
);
}
class Modules {
Modules(Client client) {
base = _ihk4p5t7.Caller(client);
@ -73,13 +115,16 @@ class Client extends _isc.ServerpodClientShared {
disconnectStreamsOnLostInternetConnection,
httpClientOverride: httpClientOverride,
) {
search = EndpointSearch(this);
modules = Modules(this);
}
late final EndpointSearch search;
late final Modules modules;
@override
Map<String, _isc.EndpointRef> get endpointRefLookup => {};
Map<String, _isc.EndpointRef> get endpointRefLookup => {'search': search};
@override
Map<String, _isc.ModuleEndpointCaller> get moduleLookup => {

View file

@ -13,6 +13,8 @@
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'package:halkyon_base_client/halkyon_base_client.dart' as _ihk4p5t7;
import 'package:halkyon_oidc_client/halkyon_oidc_client.dart' as _ibkpwzgl;
import 'package:halkyon_todo_client/src/protocol/search/place.dart'
as _i15767wh;
import 'package:serverpod_auth_core_client/serverpod_auth_core_client.dart'
as _iacc;
import 'package:serverpod_auth_idp_client/serverpod_auth_idp_client.dart'
@ -25,12 +27,18 @@ import 'label/label.dart' as _ivak08z4;
import 'privacy/content_key.dart' as _imed3x5d;
import 'project/project.dart' as _i9c44spp;
import 'project/section.dart' as _imadkvi9;
import 'search/place.dart' as _iadh29yw;
import 'search/place_point.dart' as _itf0ur2u;
import 'search/search_refused.dart' as _ihbxkwke;
import 'settings/todo_settings.dart' as _icak60vd;
import 'task/task.dart' as _ib4f8uyw;
export 'label/label.dart';
export 'privacy/content_key.dart';
export 'project/project.dart';
export 'project/section.dart';
export 'search/place.dart';
export 'search/place_point.dart';
export 'search/search_refused.dart';
export 'settings/todo_settings.dart';
export 'task/task.dart';
export 'client.dart';
@ -501,6 +509,15 @@ class Protocol extends _isd.DatabaseSerializationManager {
if (t == _imadkvi9.Section) {
return _imadkvi9.Section.fromJson(data) as T;
}
if (t == _iadh29yw.Place) {
return _iadh29yw.Place.fromJson(data) as T;
}
if (t == _itf0ur2u.PlacePoint) {
return _itf0ur2u.PlacePoint.fromJson(data) as T;
}
if (t == _ihbxkwke.SearchRefused) {
return _ihbxkwke.SearchRefused.fromJson(data) as T;
}
if (t == _icak60vd.TodoSettings) {
return _icak60vd.TodoSettings.fromJson(data) as T;
}
@ -519,12 +536,26 @@ class Protocol extends _isd.DatabaseSerializationManager {
if (t == _isc.getType<_imadkvi9.Section?>()) {
return (data != null ? _imadkvi9.Section.fromJson(data) : null) as T;
}
if (t == _isc.getType<_iadh29yw.Place?>()) {
return (data != null ? _iadh29yw.Place.fromJson(data) : null) as T;
}
if (t == _isc.getType<_itf0ur2u.PlacePoint?>()) {
return (data != null ? _itf0ur2u.PlacePoint.fromJson(data) : null) as T;
}
if (t == _isc.getType<_ihbxkwke.SearchRefused?>()) {
return (data != null ? _ihbxkwke.SearchRefused.fromJson(data) : null)
as T;
}
if (t == _isc.getType<_icak60vd.TodoSettings?>()) {
return (data != null ? _icak60vd.TodoSettings.fromJson(data) : null) as T;
}
if (t == _isc.getType<_ib4f8uyw.Task?>()) {
return (data != null ? _ib4f8uyw.Task.fromJson(data) : null) as T;
}
if (t == List<_i15767wh.Place>) {
return (data as List).map((e) => deserialize<_i15767wh.Place>(e)).toList()
as T;
}
try {
return _ihk4p5t7.Protocol().deserialize<T>(data, t);
} on _isc.DeserializationTypeNotFoundException catch (_) {}
@ -549,6 +580,9 @@ class Protocol extends _isd.DatabaseSerializationManager {
_imed3x5d.ContentKey => 'ContentKey',
_i9c44spp.Project => 'Project',
_imadkvi9.Section => 'Section',
_iadh29yw.Place => 'Place',
_itf0ur2u.PlacePoint => 'PlacePoint',
_ihbxkwke.SearchRefused => 'SearchRefused',
_icak60vd.TodoSettings => 'TodoSettings',
_ib4f8uyw.Task => 'Task',
_ => null,
@ -576,6 +610,12 @@ class Protocol extends _isd.DatabaseSerializationManager {
return 'Project';
case _imadkvi9.Section():
return 'Section';
case _iadh29yw.Place():
return 'Place';
case _itf0ur2u.PlacePoint():
return 'PlacePoint';
case _ihbxkwke.SearchRefused():
return 'SearchRefused';
case _icak60vd.TodoSettings():
return 'TodoSettings';
case _ib4f8uyw.Task():
@ -628,6 +668,15 @@ class Protocol extends _isd.DatabaseSerializationManager {
if (dataClassName == 'Section') {
return deserialize<_imadkvi9.Section>(data['data']);
}
if (dataClassName == 'Place') {
return deserialize<_iadh29yw.Place>(data['data']);
}
if (dataClassName == 'PlacePoint') {
return deserialize<_itf0ur2u.PlacePoint>(data['data']);
}
if (dataClassName == 'SearchRefused') {
return deserialize<_ihbxkwke.SearchRefused>(data['data']);
}
if (dataClassName == 'TodoSettings') {
return deserialize<_icak60vd.TodoSettings>(data['data']);
}

View file

@ -0,0 +1,226 @@
/* AUTOMATICALLY GENERATED CODE DO NOT MODIFY */
/* To generate run: "serverpod generate" */
// ignore_for_file: implementation_imports
// ignore_for_file: library_private_types_in_public_api
// ignore_for_file: non_constant_identifier_names
// ignore_for_file: public_member_api_docs
// ignore_for_file: type_literal_in_constant_pattern
// ignore_for_file: use_super_parameters
// ignore_for_file: invalid_use_of_internal_member
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'package:serverpod_client/serverpod_client.dart' as _isc;
/// A place found by the instance's search (ADR 0033, D32): what the location
/// sheet lists and a task's location is made of. Never stored by the server:
/// a task's location is in its sealed payload.
abstract class Place
implements _isc.SerializableModel, _isc.ProtocolSerialization {
Place._({
required this.name,
this.detail,
required this.kind,
this.osmValue,
this.osmType,
this.osmId,
this.street,
this.housenumber,
this.district,
this.city,
required this.lat,
required this.lon,
});
factory Place({
required String name,
String? detail,
required String kind,
String? osmValue,
String? osmType,
int? osmId,
String? street,
String? housenumber,
String? district,
String? city,
required double lat,
required double lon,
}) = _PlaceImpl;
factory Place.fromJson(Map<String, dynamic> jsonSerialization) {
return Place(
name: jsonSerialization['name'] as String,
detail: jsonSerialization['detail'] as String?,
kind: jsonSerialization['kind'] as String,
osmValue: jsonSerialization['osmValue'] as String?,
osmType: jsonSerialization['osmType'] as String?,
osmId: jsonSerialization['osmId'] as int?,
street: jsonSerialization['street'] as String?,
housenumber: jsonSerialization['housenumber'] as String?,
district: jsonSerialization['district'] as String?,
city: jsonSerialization['city'] as String?,
lat: (jsonSerialization['lat'] as num).toDouble(),
lon: (jsonSerialization['lon'] as num).toDouble(),
);
}
/// Its name, or its street and number for an address.
String name;
/// Where it is, as the list shows under the name: its street, its town and
/// its country, those the name doesn't say already; null where none.
String? detail;
/// What kind of place, as Photon names it (`city`, `street`, `house`,
/// `locality`, `other`, …).
String kind;
/// What it is in OpenStreetMap (`osm_value`: `supermarket`, `restaurant`,
/// …); null where Photon doesn't say.
String? osmValue;
/// Its object in OpenStreetMap, as Photon gives it: `N`, `W` or `R`, and
/// its ID; what its opening hours are looked up and its page opened by.
String? osmType;
int? osmId;
/// Its address, each part where Photon has it.
String? street;
String? housenumber;
String? district;
String? city;
double lat;
double lon;
/// Returns a shallow copy of this [Place]
/// with some or all fields replaced by the given arguments.
@_isc.useResult
Place copyWith({
String? name,
String? detail,
String? kind,
String? osmValue,
String? osmType,
int? osmId,
String? street,
String? housenumber,
String? district,
String? city,
double? lat,
double? lon,
});
@override
Map<String, dynamic> toJson() {
return {
'__className__': 'Place',
'name': name,
if (detail != null) 'detail': detail,
'kind': kind,
if (osmValue != null) 'osmValue': osmValue,
if (osmType != null) 'osmType': osmType,
if (osmId != null) 'osmId': osmId,
if (street != null) 'street': street,
if (housenumber != null) 'housenumber': housenumber,
if (district != null) 'district': district,
if (city != null) 'city': city,
'lat': lat,
'lon': lon,
};
}
@override
Map<String, dynamic> toJsonForProtocol() {
return {
'__className__': 'Place',
'name': name,
if (detail != null) 'detail': detail,
'kind': kind,
if (osmValue != null) 'osmValue': osmValue,
if (osmType != null) 'osmType': osmType,
if (osmId != null) 'osmId': osmId,
if (street != null) 'street': street,
if (housenumber != null) 'housenumber': housenumber,
if (district != null) 'district': district,
if (city != null) 'city': city,
'lat': lat,
'lon': lon,
};
}
@override
String toString() {
return _isc.SerializationManager.encode(this);
}
}
class _Undefined {}
class _PlaceImpl extends Place {
_PlaceImpl({
required String name,
String? detail,
required String kind,
String? osmValue,
String? osmType,
int? osmId,
String? street,
String? housenumber,
String? district,
String? city,
required double lat,
required double lon,
}) : super._(
name: name,
detail: detail,
kind: kind,
osmValue: osmValue,
osmType: osmType,
osmId: osmId,
street: street,
housenumber: housenumber,
district: district,
city: city,
lat: lat,
lon: lon,
);
/// Returns a shallow copy of this [Place]
/// with some or all fields replaced by the given arguments.
@_isc.useResult
@override
Place copyWith({
String? name,
Object? detail = _Undefined,
String? kind,
Object? osmValue = _Undefined,
Object? osmType = _Undefined,
Object? osmId = _Undefined,
Object? street = _Undefined,
Object? housenumber = _Undefined,
Object? district = _Undefined,
Object? city = _Undefined,
double? lat,
double? lon,
}) {
return Place(
name: name ?? this.name,
detail: detail is String? ? detail : this.detail,
kind: kind ?? this.kind,
osmValue: osmValue is String? ? osmValue : this.osmValue,
osmType: osmType is String? ? osmType : this.osmType,
osmId: osmId is int? ? osmId : this.osmId,
street: street is String? ? street : this.street,
housenumber: housenumber is String? ? housenumber : this.housenumber,
district: district is String? ? district : this.district,
city: city is String? ? city : this.city,
lat: lat ?? this.lat,
lon: lon ?? this.lon,
);
}
}

View file

@ -0,0 +1,93 @@
/* AUTOMATICALLY GENERATED CODE DO NOT MODIFY */
/* To generate run: "serverpod generate" */
// ignore_for_file: implementation_imports
// ignore_for_file: library_private_types_in_public_api
// ignore_for_file: non_constant_identifier_names
// ignore_for_file: public_member_api_docs
// ignore_for_file: type_literal_in_constant_pattern
// ignore_for_file: use_super_parameters
// ignore_for_file: invalid_use_of_internal_member
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'package:serverpod_client/serverpod_client.dart' as _isc;
/// A point on the earth, in degrees (WGS 84): what a search's places are
/// nearest to first (ADR 0033, D32).
abstract class PlacePoint
implements _isc.SerializableModel, _isc.ProtocolSerialization {
PlacePoint._({
required this.lat,
required this.lon,
});
factory PlacePoint({
required double lat,
required double lon,
}) = _PlacePointImpl;
factory PlacePoint.fromJson(Map<String, dynamic> jsonSerialization) {
return PlacePoint(
lat: (jsonSerialization['lat'] as num).toDouble(),
lon: (jsonSerialization['lon'] as num).toDouble(),
);
}
double lat;
double lon;
/// Returns a shallow copy of this [PlacePoint]
/// with some or all fields replaced by the given arguments.
@_isc.useResult
PlacePoint copyWith({
double? lat,
double? lon,
});
@override
Map<String, dynamic> toJson() {
return {
'__className__': 'PlacePoint',
'lat': lat,
'lon': lon,
};
}
@override
Map<String, dynamic> toJsonForProtocol() {
return {
'__className__': 'PlacePoint',
'lat': lat,
'lon': lon,
};
}
@override
String toString() {
return _isc.SerializationManager.encode(this);
}
}
class _PlacePointImpl extends PlacePoint {
_PlacePointImpl({
required double lat,
required double lon,
}) : super._(
lat: lat,
lon: lon,
);
/// Returns a shallow copy of this [PlacePoint]
/// with some or all fields replaced by the given arguments.
@_isc.useResult
@override
PlacePoint copyWith({
double? lat,
double? lon,
}) {
return PlacePoint(
lat: lat ?? this.lat,
lon: lon ?? this.lon,
);
}
}

View file

@ -0,0 +1,71 @@
/* AUTOMATICALLY GENERATED CODE DO NOT MODIFY */
/* To generate run: "serverpod generate" */
// ignore_for_file: implementation_imports
// ignore_for_file: library_private_types_in_public_api
// ignore_for_file: non_constant_identifier_names
// ignore_for_file: public_member_api_docs
// ignore_for_file: type_literal_in_constant_pattern
// ignore_for_file: use_super_parameters
// ignore_for_file: invalid_use_of_internal_member
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'package:serverpod_client/serverpod_client.dart' as _isc;
/// Why the instance searches nothing (ADR 0033, D32): `off` where it has no
/// Photon, `invalid` for a query shorter than `PlaceSearch.minQuery` or
/// longer than `PlaceSearch.maxQuery` characters or a place off the earth,
/// `tooOften` beyond `PlaceSearch.perMinute` searches a minute,
/// `unreachable` where Photon didn't answer.
abstract class SearchRefused
implements
_isc.SerializableException,
_isc.SerializableModel,
_isc.ProtocolSerialization {
SearchRefused._({required this.reason});
factory SearchRefused({required String reason}) = _SearchRefusedImpl;
factory SearchRefused.fromJson(Map<String, dynamic> jsonSerialization) {
return SearchRefused(reason: jsonSerialization['reason'] as String);
}
String reason;
/// Returns a shallow copy of this [SearchRefused]
/// with some or all fields replaced by the given arguments.
@_isc.useResult
SearchRefused copyWith({String? reason});
@override
Map<String, dynamic> toJson() {
return {
'__className__': 'SearchRefused',
'reason': reason,
};
}
@override
Map<String, dynamic> toJsonForProtocol() {
return {
'__className__': 'SearchRefused',
'reason': reason,
};
}
@override
String toString() {
return 'SearchRefused(reason: $reason)';
}
}
class _SearchRefusedImpl extends SearchRefused {
_SearchRefusedImpl({required String reason}) : super._(reason: reason);
/// Returns a shallow copy of this [SearchRefused]
/// with some or all fields replaced by the given arguments.
@_isc.useResult
@override
SearchRefused copyWith({String? reason}) {
return SearchRefused(reason: reason ?? this.reason);
}
}

View file

@ -14,6 +14,8 @@ import 'package:halkyon_base_server/halkyon_base_server.dart' as _ik8molhd;
import 'package:halkyon_oidc_server/halkyon_oidc_server.dart' as _ipoqfiqr;
import 'package:halkyon_todo_server/src/generated/future_calls.dart'
as _i772x17y;
import 'package:halkyon_todo_server/src/generated/search/place_point.dart'
as _iywv9say;
import 'package:serverpod/serverpod.dart' as _is;
import 'package:serverpod_auth_core_server/serverpod_auth_core_server.dart'
as _iacs;
@ -21,11 +23,67 @@ import 'package:serverpod_auth_idp_server/serverpod_auth_idp_server.dart'
as _iais;
import 'package:serverpod_offline_sync_server/serverpod_offline_sync_server.dart'
as _izehhkf5;
import '../search/search_endpoint.dart' as _i2f0v2ey;
export 'future_calls.dart' show ServerpodFutureCallsGetter;
class Endpoints extends _is.EndpointDispatch {
@override
void initializeEndpoints(_is.Server server) {
var endpoints = <String, _is.Endpoint>{
'search': _i2f0v2ey.SearchEndpoint()
..initialize(
server,
'search',
null,
),
};
connectors['search'] = _is.EndpointConnector(
name: 'search',
endpoint: endpoints['search']!,
methodConnectors: {
'offered': _is.MethodConnector(
name: 'offered',
params: {},
call:
(
_is.Session session,
Map<String, dynamic> params,
) async => (endpoints['search'] as _i2f0v2ey.SearchEndpoint)
.offered(session),
),
'places': _is.MethodConnector(
name: 'places',
params: {
'query': _is.ParameterDescription(
name: 'query',
type: _is.getType<String>(),
nullable: false,
),
'near': _is.ParameterDescription(
name: 'near',
type: _is.getType<_iywv9say.PlacePoint?>(),
nullable: true,
),
'language': _is.ParameterDescription(
name: 'language',
type: _is.getType<String?>(),
nullable: true,
),
},
call:
(
_is.Session session,
Map<String, dynamic> params,
) async =>
(endpoints['search'] as _i2f0v2ey.SearchEndpoint).places(
session,
params['query'],
near: params['near'],
language: params['language'],
),
),
},
);
modules['halkyon_base'] = _ik8molhd.Endpoints()
..initializeEndpoints(server);
modules['serverpod_auth_idp'] = _iais.Endpoints()

View file

@ -13,6 +13,8 @@
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'package:halkyon_base_server/halkyon_base_server.dart' as _ik8molhd;
import 'package:halkyon_oidc_server/halkyon_oidc_server.dart' as _ipoqfiqr;
import 'package:halkyon_todo_server/src/generated/search/place.dart'
as _ih6de0l6;
import 'package:serverpod/protocol.dart' as _isp;
import 'package:serverpod/serverpod.dart' as _is;
import 'package:serverpod_auth_core_server/serverpod_auth_core_server.dart'
@ -25,12 +27,18 @@ import 'label/label.dart' as _ivak08z4;
import 'privacy/content_key.dart' as _imed3x5d;
import 'project/project.dart' as _i9c44spp;
import 'project/section.dart' as _imadkvi9;
import 'search/place.dart' as _iadh29yw;
import 'search/place_point.dart' as _itf0ur2u;
import 'search/search_refused.dart' as _ihbxkwke;
import 'settings/todo_settings.dart' as _icak60vd;
import 'task/task.dart' as _ib4f8uyw;
export 'label/label.dart';
export 'privacy/content_key.dart';
export 'project/project.dart';
export 'project/section.dart';
export 'search/place.dart';
export 'search/place_point.dart';
export 'search/search_refused.dart';
export 'settings/todo_settings.dart';
export 'task/task.dart';
export 'sync_tables.dart';
@ -486,6 +494,15 @@ class Protocol extends _is.DatabaseSerializationManager {
if (t == _imadkvi9.Section) {
return _imadkvi9.Section.fromJson(data) as T;
}
if (t == _iadh29yw.Place) {
return _iadh29yw.Place.fromJson(data) as T;
}
if (t == _itf0ur2u.PlacePoint) {
return _itf0ur2u.PlacePoint.fromJson(data) as T;
}
if (t == _ihbxkwke.SearchRefused) {
return _ihbxkwke.SearchRefused.fromJson(data) as T;
}
if (t == _icak60vd.TodoSettings) {
return _icak60vd.TodoSettings.fromJson(data) as T;
}
@ -504,12 +521,26 @@ class Protocol extends _is.DatabaseSerializationManager {
if (t == _is.getType<_imadkvi9.Section?>()) {
return (data != null ? _imadkvi9.Section.fromJson(data) : null) as T;
}
if (t == _is.getType<_iadh29yw.Place?>()) {
return (data != null ? _iadh29yw.Place.fromJson(data) : null) as T;
}
if (t == _is.getType<_itf0ur2u.PlacePoint?>()) {
return (data != null ? _itf0ur2u.PlacePoint.fromJson(data) : null) as T;
}
if (t == _is.getType<_ihbxkwke.SearchRefused?>()) {
return (data != null ? _ihbxkwke.SearchRefused.fromJson(data) : null)
as T;
}
if (t == _is.getType<_icak60vd.TodoSettings?>()) {
return (data != null ? _icak60vd.TodoSettings.fromJson(data) : null) as T;
}
if (t == _is.getType<_ib4f8uyw.Task?>()) {
return (data != null ? _ib4f8uyw.Task.fromJson(data) : null) as T;
}
if (t == List<_ih6de0l6.Place>) {
return (data as List).map((e) => deserialize<_ih6de0l6.Place>(e)).toList()
as T;
}
try {
return _ik8molhd.Protocol().deserialize<T>(data, t);
} on _is.DeserializationTypeNotFoundException catch (_) {}
@ -537,6 +568,9 @@ class Protocol extends _is.DatabaseSerializationManager {
_imed3x5d.ContentKey => 'ContentKey',
_i9c44spp.Project => 'Project',
_imadkvi9.Section => 'Section',
_iadh29yw.Place => 'Place',
_itf0ur2u.PlacePoint => 'PlacePoint',
_ihbxkwke.SearchRefused => 'SearchRefused',
_icak60vd.TodoSettings => 'TodoSettings',
_ib4f8uyw.Task => 'Task',
_ => null,
@ -564,6 +598,12 @@ class Protocol extends _is.DatabaseSerializationManager {
return 'Project';
case _imadkvi9.Section():
return 'Section';
case _iadh29yw.Place():
return 'Place';
case _itf0ur2u.PlacePoint():
return 'PlacePoint';
case _ihbxkwke.SearchRefused():
return 'SearchRefused';
case _icak60vd.TodoSettings():
return 'TodoSettings';
case _ib4f8uyw.Task():
@ -620,6 +660,15 @@ class Protocol extends _is.DatabaseSerializationManager {
if (dataClassName == 'Section') {
return deserialize<_imadkvi9.Section>(data['data']);
}
if (dataClassName == 'Place') {
return deserialize<_iadh29yw.Place>(data['data']);
}
if (dataClassName == 'PlacePoint') {
return deserialize<_itf0ur2u.PlacePoint>(data['data']);
}
if (dataClassName == 'SearchRefused') {
return deserialize<_ihbxkwke.SearchRefused>(data['data']);
}
if (dataClassName == 'TodoSettings') {
return deserialize<_icak60vd.TodoSettings>(data['data']);
}

View file

@ -0,0 +1,3 @@
search:
- offered:
- places:

View file

@ -0,0 +1,226 @@
/* AUTOMATICALLY GENERATED CODE DO NOT MODIFY */
/* To generate run: "serverpod generate" */
// ignore_for_file: implementation_imports
// ignore_for_file: library_private_types_in_public_api
// ignore_for_file: non_constant_identifier_names
// ignore_for_file: public_member_api_docs
// ignore_for_file: type_literal_in_constant_pattern
// ignore_for_file: use_super_parameters
// ignore_for_file: invalid_use_of_internal_member
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'package:serverpod/serverpod.dart' as _is;
/// A place found by the instance's search (ADR 0033, D32): what the location
/// sheet lists and a task's location is made of. Never stored by the server:
/// a task's location is in its sealed payload.
abstract class Place
implements _is.SerializableModel, _is.ProtocolSerialization {
Place._({
required this.name,
this.detail,
required this.kind,
this.osmValue,
this.osmType,
this.osmId,
this.street,
this.housenumber,
this.district,
this.city,
required this.lat,
required this.lon,
});
factory Place({
required String name,
String? detail,
required String kind,
String? osmValue,
String? osmType,
int? osmId,
String? street,
String? housenumber,
String? district,
String? city,
required double lat,
required double lon,
}) = _PlaceImpl;
factory Place.fromJson(Map<String, dynamic> jsonSerialization) {
return Place(
name: jsonSerialization['name'] as String,
detail: jsonSerialization['detail'] as String?,
kind: jsonSerialization['kind'] as String,
osmValue: jsonSerialization['osmValue'] as String?,
osmType: jsonSerialization['osmType'] as String?,
osmId: jsonSerialization['osmId'] as int?,
street: jsonSerialization['street'] as String?,
housenumber: jsonSerialization['housenumber'] as String?,
district: jsonSerialization['district'] as String?,
city: jsonSerialization['city'] as String?,
lat: (jsonSerialization['lat'] as num).toDouble(),
lon: (jsonSerialization['lon'] as num).toDouble(),
);
}
/// Its name, or its street and number for an address.
String name;
/// Where it is, as the list shows under the name: its street, its town and
/// its country, those the name doesn't say already; null where none.
String? detail;
/// What kind of place, as Photon names it (`city`, `street`, `house`,
/// `locality`, `other`, …).
String kind;
/// What it is in OpenStreetMap (`osm_value`: `supermarket`, `restaurant`,
/// …); null where Photon doesn't say.
String? osmValue;
/// Its object in OpenStreetMap, as Photon gives it: `N`, `W` or `R`, and
/// its ID; what its opening hours are looked up and its page opened by.
String? osmType;
int? osmId;
/// Its address, each part where Photon has it.
String? street;
String? housenumber;
String? district;
String? city;
double lat;
double lon;
/// Returns a shallow copy of this [Place]
/// with some or all fields replaced by the given arguments.
@_is.useResult
Place copyWith({
String? name,
String? detail,
String? kind,
String? osmValue,
String? osmType,
int? osmId,
String? street,
String? housenumber,
String? district,
String? city,
double? lat,
double? lon,
});
@override
Map<String, dynamic> toJson() {
return {
'__className__': 'Place',
'name': name,
if (detail != null) 'detail': detail,
'kind': kind,
if (osmValue != null) 'osmValue': osmValue,
if (osmType != null) 'osmType': osmType,
if (osmId != null) 'osmId': osmId,
if (street != null) 'street': street,
if (housenumber != null) 'housenumber': housenumber,
if (district != null) 'district': district,
if (city != null) 'city': city,
'lat': lat,
'lon': lon,
};
}
@override
Map<String, dynamic> toJsonForProtocol() {
return {
'__className__': 'Place',
'name': name,
if (detail != null) 'detail': detail,
'kind': kind,
if (osmValue != null) 'osmValue': osmValue,
if (osmType != null) 'osmType': osmType,
if (osmId != null) 'osmId': osmId,
if (street != null) 'street': street,
if (housenumber != null) 'housenumber': housenumber,
if (district != null) 'district': district,
if (city != null) 'city': city,
'lat': lat,
'lon': lon,
};
}
@override
String toString() {
return _is.SerializationManager.encode(this);
}
}
class _Undefined {}
class _PlaceImpl extends Place {
_PlaceImpl({
required String name,
String? detail,
required String kind,
String? osmValue,
String? osmType,
int? osmId,
String? street,
String? housenumber,
String? district,
String? city,
required double lat,
required double lon,
}) : super._(
name: name,
detail: detail,
kind: kind,
osmValue: osmValue,
osmType: osmType,
osmId: osmId,
street: street,
housenumber: housenumber,
district: district,
city: city,
lat: lat,
lon: lon,
);
/// Returns a shallow copy of this [Place]
/// with some or all fields replaced by the given arguments.
@_is.useResult
@override
Place copyWith({
String? name,
Object? detail = _Undefined,
String? kind,
Object? osmValue = _Undefined,
Object? osmType = _Undefined,
Object? osmId = _Undefined,
Object? street = _Undefined,
Object? housenumber = _Undefined,
Object? district = _Undefined,
Object? city = _Undefined,
double? lat,
double? lon,
}) {
return Place(
name: name ?? this.name,
detail: detail is String? ? detail : this.detail,
kind: kind ?? this.kind,
osmValue: osmValue is String? ? osmValue : this.osmValue,
osmType: osmType is String? ? osmType : this.osmType,
osmId: osmId is int? ? osmId : this.osmId,
street: street is String? ? street : this.street,
housenumber: housenumber is String? ? housenumber : this.housenumber,
district: district is String? ? district : this.district,
city: city is String? ? city : this.city,
lat: lat ?? this.lat,
lon: lon ?? this.lon,
);
}
}

View file

@ -0,0 +1,93 @@
/* AUTOMATICALLY GENERATED CODE DO NOT MODIFY */
/* To generate run: "serverpod generate" */
// ignore_for_file: implementation_imports
// ignore_for_file: library_private_types_in_public_api
// ignore_for_file: non_constant_identifier_names
// ignore_for_file: public_member_api_docs
// ignore_for_file: type_literal_in_constant_pattern
// ignore_for_file: use_super_parameters
// ignore_for_file: invalid_use_of_internal_member
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'package:serverpod/serverpod.dart' as _is;
/// A point on the earth, in degrees (WGS 84): what a search's places are
/// nearest to first (ADR 0033, D32).
abstract class PlacePoint
implements _is.SerializableModel, _is.ProtocolSerialization {
PlacePoint._({
required this.lat,
required this.lon,
});
factory PlacePoint({
required double lat,
required double lon,
}) = _PlacePointImpl;
factory PlacePoint.fromJson(Map<String, dynamic> jsonSerialization) {
return PlacePoint(
lat: (jsonSerialization['lat'] as num).toDouble(),
lon: (jsonSerialization['lon'] as num).toDouble(),
);
}
double lat;
double lon;
/// Returns a shallow copy of this [PlacePoint]
/// with some or all fields replaced by the given arguments.
@_is.useResult
PlacePoint copyWith({
double? lat,
double? lon,
});
@override
Map<String, dynamic> toJson() {
return {
'__className__': 'PlacePoint',
'lat': lat,
'lon': lon,
};
}
@override
Map<String, dynamic> toJsonForProtocol() {
return {
'__className__': 'PlacePoint',
'lat': lat,
'lon': lon,
};
}
@override
String toString() {
return _is.SerializationManager.encode(this);
}
}
class _PlacePointImpl extends PlacePoint {
_PlacePointImpl({
required double lat,
required double lon,
}) : super._(
lat: lat,
lon: lon,
);
/// Returns a shallow copy of this [PlacePoint]
/// with some or all fields replaced by the given arguments.
@_is.useResult
@override
PlacePoint copyWith({
double? lat,
double? lon,
}) {
return PlacePoint(
lat: lat ?? this.lat,
lon: lon ?? this.lon,
);
}
}

View file

@ -0,0 +1,71 @@
/* AUTOMATICALLY GENERATED CODE DO NOT MODIFY */
/* To generate run: "serverpod generate" */
// ignore_for_file: implementation_imports
// ignore_for_file: library_private_types_in_public_api
// ignore_for_file: non_constant_identifier_names
// ignore_for_file: public_member_api_docs
// ignore_for_file: type_literal_in_constant_pattern
// ignore_for_file: use_super_parameters
// ignore_for_file: invalid_use_of_internal_member
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'package:serverpod/serverpod.dart' as _is;
/// Why the instance searches nothing (ADR 0033, D32): `off` where it has no
/// Photon, `invalid` for a query shorter than `PlaceSearch.minQuery` or
/// longer than `PlaceSearch.maxQuery` characters or a place off the earth,
/// `tooOften` beyond `PlaceSearch.perMinute` searches a minute,
/// `unreachable` where Photon didn't answer.
abstract class SearchRefused
implements
_is.SerializableException,
_is.SerializableModel,
_is.ProtocolSerialization {
SearchRefused._({required this.reason});
factory SearchRefused({required String reason}) = _SearchRefusedImpl;
factory SearchRefused.fromJson(Map<String, dynamic> jsonSerialization) {
return SearchRefused(reason: jsonSerialization['reason'] as String);
}
String reason;
/// Returns a shallow copy of this [SearchRefused]
/// with some or all fields replaced by the given arguments.
@_is.useResult
SearchRefused copyWith({String? reason});
@override
Map<String, dynamic> toJson() {
return {
'__className__': 'SearchRefused',
'reason': reason,
};
}
@override
Map<String, dynamic> toJsonForProtocol() {
return {
'__className__': 'SearchRefused',
'reason': reason,
};
}
@override
String toString() {
return 'SearchRefused(reason: $reason)';
}
}
class _SearchRefusedImpl extends SearchRefused {
_SearchRefusedImpl({required String reason}) : super._(reason: reason);
/// Returns a shallow copy of this [SearchRefused]
/// with some or all fields replaced by the given arguments.
@_is.useResult
@override
SearchRefused copyWith({String? reason}) {
return SearchRefused(reason: reason ?? this.reason);
}
}

View file

@ -0,0 +1,138 @@
import 'dart:convert';
import 'dart:io';
import 'package:halkyon_todo_server/src/generated/protocol.dart';
/// Finds places for a query (ADR 0033, D32): the instance's Photon, or a
/// fake in tests. Answers Photon's GeoJSON, else throws [PhotonFailed].
abstract interface class PlaceFinder {
Future<Map<String, Object?>> search(
String query, {
PlacePoint? near,
String? language,
required int limit,
});
}
/// Photon didn't answer, or not with GeoJSON.
final class const PhotonFailed(final String reason) implements Exception {
@override
String toString() => 'PhotonFailed: $reason';
}
/// Photon (v1.3.0) at [address], asked over plain HTTP on the network it
/// shares with Todo's server alone: `/api` with the query, the point its
/// results are nearest to first, the language of their names.
class HttpPhoton(final Uri address) implements PlaceFinder {
static const _timeout = Duration(seconds: 10);
@override
Future<Map<String, Object?>> search(
String query, {
PlacePoint? near,
String? language,
required int limit,
}) async {
final client = HttpClient()..connectionTimeout = _timeout;
try {
final uri = address
.resolve('api')
.replace(
queryParameters: {
'q': query,
'limit': '$limit',
if (near != null) ...{
'lat': '${near.lat}',
'lon': '${near.lon}',
},
'lang': ?language,
},
);
final HttpClientResponse response;
final String body;
try {
response = await (await client.getUrl(uri)).close().timeout(_timeout);
body = await utf8.decodeStream(response).timeout(_timeout);
} on Object catch (error) {
throw PhotonFailed(error.runtimeType.toString());
}
if (response.statusCode != HttpStatus.ok) {
throw PhotonFailed('HTTP ${response.statusCode}');
}
try {
return jsonDecode(body) as Map<String, Object?>;
} on Object {
throw const PhotonFailed('no GeoJSON');
}
} finally {
client.close(force: true);
}
}
}
/// What OpenStreetMap calls what is no place to go, however a shop's car park
/// or charger is named: searching for `EDEKA` should find the shop.
const _noDestination = {
'charging_station',
'parking',
'parking_entrance',
'parking_space',
};
/// The places in Photon's [geoJson], in its order: each feature a point
/// with a name, or a street (with its number) for an address; what is no
/// place to go and the rest left out.
List<Place> placesOf(Map<String, Object?> geoJson) => [
for (final feature in switch (geoJson['features']) {
final List<Object?> features => features,
_ => const <Object?>[],
})
if (feature case {
'geometry': {
'type': 'Point',
'coordinates': [final num lon, final num lat, ...],
},
'properties': final Map<String, Object?> properties,
})
?_placeOf(properties, lat.toDouble(), lon.toDouble()),
];
Place? _placeOf(Map<String, Object?> properties, double lat, double lon) {
String? text(String key) => switch (properties[key]) {
final String value when value.trim().isNotEmpty => value.trim(),
_ => null,
};
final street = switch ((text('street'), text('housenumber'))) {
(final street?, final number?) => '$street $number',
(final street?, null) => street,
_ => null,
};
final name = text('name') ?? street;
final osmValue = text('osm_value');
if (name == null || _noDestination.contains(osmValue)) {
return null;
}
final town = text('city') ?? text('district') ?? text('county');
final detail = <String>[
if (street != null && street != name) street,
if (town != null && town != name) town,
if (text('country') case final country? when country != name) country,
];
return Place(
name: name,
detail: detail.isEmpty ? null : detail.join(', '),
kind: text('type') ?? 'other',
osmValue: osmValue,
osmType: text('osm_type'),
osmId: switch (properties['osm_id']) {
final num id => id.toInt(),
_ => null,
},
street: text('street'),
housenumber: text('housenumber'),
district: text('district'),
city: text('city'),
lat: lat,
lon: lon,
);
}

View file

@ -0,0 +1,27 @@
### A place found by the instance's search (ADR 0033, D32): what the location
### sheet lists and a task's location is made of. Never stored by the server:
### a task's location is in its sealed payload.
class: Place
fields:
### Its name, or its street and number for an address.
name: String
### Where it is, as the list shows under the name: its street, its town and
### its country, those the name doesn't say already; null where none.
detail: String?
### What kind of place, as Photon names it (`city`, `street`, `house`,
### `locality`, `other`, …).
kind: String
### What it is in OpenStreetMap (`osm_value`: `supermarket`, `restaurant`,
### …); null where Photon doesn't say.
osmValue: String?
### Its object in OpenStreetMap, as Photon gives it: `N`, `W` or `R`, and
### its ID; what its opening hours are looked up and its page opened by.
osmType: String?
osmId: int?
### Its address, each part where Photon has it.
street: String?
housenumber: String?
district: String?
city: String?
lat: double
lon: double

View file

@ -0,0 +1,6 @@
### A point on the earth, in degrees (WGS 84): what a search's places are
### nearest to first (ADR 0033, D32).
class: PlacePoint
fields:
lat: double
lon: double

View file

@ -0,0 +1,112 @@
import 'dart:collection';
import 'dart:io';
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:serverpod/serverpod.dart';
/// Searching for places on this instance (ADR 0033, D32), through its Photon
/// where it has one, for a person as often as [perMinute] allows. Neither
/// the query nor the places are kept or logged; the counts live in memory
/// alone.
class PlaceSearch {
PlaceSearch({this.finder});
/// The search of [pod], made the first time it is asked for, from the
/// operator's `PHOTON_URL` (empty: none). Tests set their own.
factory PlaceSearch.of(Serverpod pod) => _ofPod[pod] ??= PlaceSearch(
finder: switch (Platform.environment['PHOTON_URL']?.trim()) {
final url? when url.isNotEmpty => HttpPhoton(Uri.parse(url)),
_ => null,
},
);
static final _ofPod = Expando<PlaceSearch>('PlaceSearch');
/// Makes [search] the one of [pod].
static void use(Serverpod pod, PlaceSearch search) => _ofPod[pod] = search;
/// The instance's Photon; none: it finds nothing.
final PlaceFinder? finder;
/// The fewest and the most characters a query has.
static const minQuery = 2;
static const maxQuery = 200;
/// The most places an answer lists.
static const limit = 8;
/// The most asked of Photon, as what is no place to go is left out of it.
static const _askedOfPhoton = 12;
/// The most searches a person may make in a minute: the location sheet asks
/// as they type, a pause after each word.
static const perMinute = 60;
/// The languages Photon names places in; any other gets its own names.
static const languages = {'en', 'de'};
final _asked = HashMap<UuidValue, Queue<DateTime>>();
/// The places [query] finds for [person], those nearest [near] first, named
/// in [language]. Refused ([SearchRefused]) without a Photon, for a query
/// or a point it doesn't take, beyond [perMinute], where Photon doesn't
/// answer.
Future<List<Place>> places(
UuidValue person,
String query, {
PlacePoint? near,
String? language,
}) async {
final finder = this.finder;
if (finder == null) {
throw SearchRefused(reason: 'off');
}
final trimmed = query.trim();
if (trimmed.length < minQuery ||
trimmed.length > maxQuery ||
(near != null && !_onEarth(near))) {
throw SearchRefused(reason: 'invalid');
}
_count(person);
try {
return placesOf(
await finder.search(
trimmed,
near: near,
language: languages.contains(language) ? language : null,
limit: _askedOfPhoton,
),
).take(limit).toList();
} on PhotonFailed {
throw SearchRefused(reason: 'unreachable');
}
}
static bool _onEarth(PlacePoint point) =>
point.lat.isFinite &&
point.lon.isFinite &&
point.lat.abs() <= 90 &&
point.lon.abs() <= 180;
/// Counts a search of [person]'s, refusing it beyond [perMinute] in the
/// last minute.
void _count(UuidValue person) {
final now = clock.now();
final asked = _asked.putIfAbsent(person, Queue.new);
while (asked.isNotEmpty &&
now.difference(asked.first) >= const Duration(minutes: 1)) {
asked.removeFirst();
}
if (asked.length >= perMinute) {
throw SearchRefused(reason: 'tooOften');
}
asked.addLast(now);
// Those who searched nothing for a minute are forgotten.
_asked.removeWhere(
(_, times) =>
times.isEmpty ||
now.difference(times.last) >= const Duration(minutes: 1),
);
}
}

View file

@ -0,0 +1,34 @@
import 'package:halkyon_todo_server/src/generated/protocol.dart';
import 'package:halkyon_todo_server/src/search/place_search.dart';
import 'package:serverpod/serverpod.dart';
import 'package:serverpod_auth_idp_server/core.dart';
/// Places found by the instance's Photon (ADR 0033, D32), for those signed
/// in: Photon has no sign-in of its own, so the server alone reaches it.
/// Neither the query nor the places are kept or logged, and a task's location
/// stays in its sealed payload. Refusals are `SearchRefused`.
class SearchEndpoint extends Endpoint {
@override
bool get requireLogin => true;
/// Whether the instance searches for places.
Future<bool> offered(Session session) async =>
PlaceSearch.of(session.serverpod).finder != null;
/// The places [query] finds, nearest [near] first (the map's centre, or
/// the person), named in [language] (`en`, `de`; else each its own name).
/// Refused where the instance has no Photon or it fails, for a query of
/// fewer than `PlaceSearch.minQuery` or more than `PlaceSearch.maxQuery`
/// characters, and beyond `PlaceSearch.perMinute` searches a minute.
Future<List<Place>> places(
Session session,
String query, {
PlacePoint? near,
String? language,
}) => PlaceSearch.of(session.serverpod).places(
session.authenticated!.authUserId,
query,
near: near,
language: language,
);
}

View file

@ -0,0 +1,8 @@
### Why the instance searches nothing (ADR 0033, D32): `off` where it has no
### Photon, `invalid` for a query shorter than `PlaceSearch.minQuery` or
### longer than `PlaceSearch.maxQuery` characters or a place off the earth,
### `tooOften` beyond `PlaceSearch.perMinute` searches a minute,
### `unreachable` where Photon didn't answer.
exception: SearchRefused
fields:
reason: String

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 =

Some files were not shown because too many files have changed in this diff Show more