Resolve IDFM service catalog with bounded importer #11
Merged@github-anthodev wants to merge
feature/resolve-idfm-services/backend/3 into developMerged as
acf5bbab8ab6.18 files changed
modified
.gitignore +1 -0 | @@ -1,4 +1,5 @@ | ||
| 1 | 1 | node_modules/ |
| 2 | +var/ | |
| 2 | 3 | packages/watch/build/ |
| 3 | 4 | packages/watch/src/pkjs/ |
| 4 | 5 | docs/ |
| @@ -1,4 +1,5 @@ | |||
| 1 | node_modules/ | 1 | node_modules/ |
| 2 | +var/ | ||
| 2 | packages/watch/build/ | 3 | packages/watch/build/ |
| 3 | packages/watch/src/pkjs/ | 4 | packages/watch/src/pkjs/ |
| 4 | docs/ | 5 | docs/ |
added
fixtures/catalog/idfm-v1.json +167 -0 | new file mode 100644 | ||
| @@ -0,0 +1,167 @@ | ||
| 1 | +{ | |
| 2 | + "schemaVersion": 1, | |
| 3 | + "fixtureVersion": "idfm-catalog-v1", | |
| 4 | + "recordedAt": "2026-08-30T00:00:00.000Z", | |
| 5 | + "dataClassification": "Schema-faithful deterministic reduction of official source layouts; fixture-only identifiers, no credential and no personal departure response.", | |
| 6 | + "attribution": [ | |
| 7 | + { "dataset": "offre-horaires-tc-gtfs-idfm", "url": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/offre-horaires-tc-gtfs-idfm", "retrievedAt": "2026-08-30T00:00:00.000Z", "license": "Licence Mobilité" }, | |
| 8 | + { "dataset": "perimetre-des-donnees-tr-disponibles-plateforme-idfm", "url": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/perimetre-des-donnees-tr-disponibles-plateforme-idfm", "retrievedAt": "2026-08-30T00:00:00.000Z", "license": "Licence Mobilité" }, | |
| 9 | + { "dataset": "arrets", "url": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/arrets", "retrievedAt": "2026-08-30T00:00:00.000Z", "license": "Licence Ouverte 2.0" }, | |
| 10 | + { "dataset": "zones-d-arrets", "url": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/zones-d-arrets", "retrievedAt": "2026-08-30T00:00:00.000Z", "license": "Licence Ouverte 2.0" }, | |
| 11 | + { "dataset": "relations", "url": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/relations", "retrievedAt": "2026-08-30T00:00:00.000Z", "license": "Licence Ouverte 2.0" }, | |
| 12 | + { "dataset": "referentiel-des-lignes", "url": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes", "retrievedAt": "2026-08-30T00:00:00.000Z", "license": "ODbL 1.0" }, | |
| 13 | + { "dataset": "arrets-lignes", "url": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/arrets-lignes", "retrievedAt": "2026-08-30T00:00:00.000Z", "license": "ODbL 1.0" } | |
| 14 | + ], | |
| 15 | + "revisions": [ | |
| 16 | + { "id": "fixture-2026-08-a", "preservedTripId": "trip-bus-preserved", "removedTripId": "trip-metro-west" }, | |
| 17 | + { "id": "fixture-2026-08-b", "preservedTripId": "trip-bus-preserved", "removedTripId": "trip-metro-west" }, | |
| 18 | + { "id": "fixture-invalid-no-transilien", "invalidReason": "No exact TRANSILIEN perimeter pair; five-mode validation rejects the candidate" } | |
| 19 | + ], | |
| 20 | + "sources": { | |
| 21 | + "agency": [ | |
| 22 | + { "agency_name": "Fixture IDFM operator", "agency_url": "https://www.iledefrance-mobilites.fr/", "agency_timezone": "Europe/Paris" } | |
| 23 | + ], | |
| 24 | + "lines": [ | |
| 25 | + { "id_line": "C100", "name_line": "Bus 42", "shortname_line": "42", "transportmode": "bus", "transportsubmode": "", "status": "active" }, | |
| 26 | + { "id_line": "C200", "name_line": "Métro 4", "shortname_line": "4", "transportmode": "metro", "transportsubmode": "", "status": "active" }, | |
| 27 | + { "id_line": "C300", "name_line": "Tram T3a", "shortname_line": "T3a", "transportmode": "tram", "transportsubmode": "", "status": "active" }, | |
| 28 | + { "id_line": "C400", "name_line": "RER A", "shortname_line": "A", "transportmode": "rail", "transportsubmode": "local", "status": "active" }, | |
| 29 | + { "id_line": "C500", "name_line": "Transilien L", "shortname_line": "L", "transportmode": "rail", "transportsubmode": "suburbanRailway", "status": "active" }, | |
| 30 | + { "id_line": "C600", "name_line": "Funiculaire", "shortname_line": "F", "transportmode": "funicular", "transportsubmode": "", "status": "active" } | |
| 31 | + ], | |
| 32 | + "routes": [ | |
| 33 | + { "route_id": "IDFM:C100", "route_short_name": "42", "route_long_name": "Bus 42" }, | |
| 34 | + { "route_id": "IDFM:C200", "route_short_name": "4", "route_long_name": "Métro 4" }, | |
| 35 | + { "route_id": "IDFM:C300", "route_short_name": "T3a", "route_long_name": "Tram T3a" }, | |
| 36 | + { "route_id": "IDFM:C400", "route_short_name": "A", "route_long_name": "RER A" }, | |
| 37 | + { "route_id": "IDFM:C500", "route_short_name": "L", "route_long_name": "Transilien L" }, | |
| 38 | + { "route_id": "IDFM:C600", "route_short_name": "F", "route_long_name": "Funiculaire" } | |
| 39 | + ], | |
| 40 | + "zones": [ | |
| 41 | + { "zdaid": "ZDA-BUS-1", "zdaname": "Gare du Nord", "zdatown": "Paris" }, | |
| 42 | + { "zdaid": "ZDA-BUS-2", "zdaname": "Gare du Nord", "zdatown": "Saint-Denis" }, | |
| 43 | + { "zdaid": "ZDA-METRO", "zdaname": "Châtelet", "zdatown": "Paris" }, | |
| 44 | + { "zdaid": "ZDA-TRAM", "zdaname": "Porte de Versailles", "zdatown": "Paris" }, | |
| 45 | + { "zdaid": "ZDA-RER", "zdaname": "Châtelet - Les Halles", "zdatown": "Paris" }, | |
| 46 | + { "zdaid": "ZDA-TRANSILIEN", "zdaname": "Saint-Lazare", "zdatown": "Paris" }, | |
| 47 | + { "zdaid": "ZDA-FUNICULAR", "zdaname": "Montmartre", "zdatown": "Paris" } | |
| 48 | + ], | |
| 49 | + "arrets": [ | |
| 50 | + { "arrid": "ARR-BUS-1", "zdaid": "ZDA-BUS-1" }, | |
| 51 | + { "arrid": "ARR-BUS-2", "zdaid": "ZDA-BUS-2" }, | |
| 52 | + { "arrid": "ARR-METRO-EAST", "zdaid": "ZDA-METRO" }, | |
| 53 | + { "arrid": "ARR-METRO-WEST", "zdaid": "ZDA-METRO" }, | |
| 54 | + { "arrid": "ARR-TRAM", "zdaid": "ZDA-TRAM" }, | |
| 55 | + { "arrid": "ARR-FUNICULAR", "zdaid": "ZDA-FUNICULAR" } | |
| 56 | + ], | |
| 57 | + "relations": [ | |
| 58 | + { "zdaid": "ZDA-BUS-1", "arrid": "ARR-BUS-1", "artid": "ART-BUS-1" }, | |
| 59 | + { "zdaid": "ZDA-BUS-2", "arrid": "ARR-BUS-2", "artid": "ART-BUS-2" }, | |
| 60 | + { "zdaid": "ZDA-METRO", "arrid": "ARR-METRO-EAST", "artid": "ART-METRO-EAST" }, | |
| 61 | + { "zdaid": "ZDA-METRO", "arrid": "ARR-METRO-WEST", "artid": "ART-METRO-WEST" }, | |
| 62 | + { "zdaid": "ZDA-TRAM", "arrid": "ARR-TRAM", "artid": "ART-TRAM" }, | |
| 63 | + { "zdaid": "ZDA-FUNICULAR", "arrid": "ARR-FUNICULAR", "artid": "ART-FUNICULAR" } | |
| 64 | + ], | |
| 65 | + "stops": [ | |
| 66 | + { "stop_id": "IDFM:ART-BUS-1", "stop_name": "Gare du Nord quai bus", "parent_station": "IDFM:ARR-BUS-1" }, | |
| 67 | + { "stop_id": "IDFM:TERM-BUS-1", "stop_name": "Hôpital Européen", "parent_station": "" }, | |
| 68 | + { "stop_id": "IDFM:ART-BUS-2", "stop_name": "Gare du Nord annexe", "parent_station": "IDFM:ARR-BUS-2" }, | |
| 69 | + { "stop_id": "IDFM:TERM-BUS-2", "stop_name": "Mairie", "parent_station": "" }, | |
| 70 | + { "stop_id": "IDFM:ART-BUS-MISMATCH", "stop_name": "Mismatch", "parent_station": "IDFM:ARR-BUS-1" }, | |
| 71 | + { "stop_id": "IDFM:TERM-BUS-MISMATCH", "stop_name": "Wrong perimeter destination", "parent_station": "" }, | |
| 72 | + { "stop_id": "IDFM:ART-METRO-EAST", "stop_name": "Châtelet sud", "parent_station": "IDFM:ARR-METRO-EAST" }, | |
| 73 | + { "stop_id": "IDFM:TERM-METRO-SOUTH", "stop_name": "Bagneux - Lucie Aubrac", "parent_station": "" }, | |
| 74 | + { "stop_id": "IDFM:ART-METRO-WEST", "stop_name": "Châtelet nord", "parent_station": "IDFM:ARR-METRO-WEST" }, | |
| 75 | + { "stop_id": "IDFM:TERM-METRO-NORTH", "stop_name": "Porte de Clignancourt", "parent_station": "" }, | |
| 76 | + { "stop_id": "IDFM:ART-TRAM", "stop_name": "Porte de Versailles quai tram", "parent_station": "IDFM:ARR-TRAM" }, | |
| 77 | + { "stop_id": "IDFM:TERM-TRAM", "stop_name": "Pont du Garigliano", "parent_station": "" }, | |
| 78 | + { "stop_id": "IDFM:ART-RER-EAST", "stop_name": "Châtelet RER est", "parent_station": "IDFM:monomodalStopPlace:ZDA-RER" }, | |
| 79 | + { "stop_id": "IDFM:TERM-RER-EAST", "stop_name": "Marne-la-Vallée Chessy", "parent_station": "" }, | |
| 80 | + { "stop_id": "IDFM:ART-RER-WEST", "stop_name": "Châtelet RER ouest", "parent_station": "IDFM:monomodalStopPlace:ZDA-RER" }, | |
| 81 | + { "stop_id": "IDFM:TERM-RER-WEST", "stop_name": "Saint-Germain-en-Laye", "parent_station": "" }, | |
| 82 | + { "stop_id": "IDFM:ART-TRANSILIEN", "stop_name": "Saint-Lazare quai L", "parent_station": "IDFM:monomodalStopPlace:ZDA-TRANSILIEN" }, | |
| 83 | + { "stop_id": "IDFM:TERM-TRANSILIEN", "stop_name": "Versailles Rive Droite", "parent_station": "" }, | |
| 84 | + { "stop_id": "IDFM:ART-FUNICULAR", "stop_name": "Funiculaire bas", "parent_station": "IDFM:ARR-FUNICULAR" }, | |
| 85 | + { "stop_id": "IDFM:TERM-FUNICULAR", "stop_name": "Funiculaire haut", "parent_station": "" } | |
| 86 | + ], | |
| 87 | + "trips": [ | |
| 88 | + { "route_id": "IDFM:C100", "trip_id": "trip-bus-preserved", "trip_headsign": "Hôpital Européen", "direction_id": "0" }, | |
| 89 | + { "route_id": "IDFM:C100", "trip_id": "trip-bus-homonym", "trip_headsign": "Mairie", "direction_id": "1" }, | |
| 90 | + { "route_id": "IDFM:C100", "trip_id": "trip-bus-no-exact-perimeter", "trip_headsign": "Wrong perimeter destination", "direction_id": "0" }, | |
| 91 | + { "route_id": "IDFM:C200", "trip_id": "trip-metro-east", "trip_headsign": "Bagneux - Lucie Aubrac", "direction_id": "0" }, | |
| 92 | + { "route_id": "IDFM:C200", "trip_id": "trip-metro-west", "trip_headsign": "Porte de Clignancourt", "direction_id": "1", "revisions": ["fixture-2026-08-a"] }, | |
| 93 | + { "route_id": "IDFM:C300", "trip_id": "trip-tram", "trip_headsign": "Pont du Garigliano", "direction_id": "0" }, | |
| 94 | + { "route_id": "IDFM:C400", "trip_id": "trip-rer-east", "trip_headsign": "NORA", "direction_id": "0" }, | |
| 95 | + { "route_id": "IDFM:C400", "trip_id": "trip-rer-west", "trip_headsign": "ZEBU", "direction_id": "1" }, | |
| 96 | + { "route_id": "IDFM:C500", "trip_id": "trip-transilien", "trip_headsign": "MILA", "direction_id": "0" }, | |
| 97 | + { "route_id": "IDFM:C600", "trip_id": "trip-funicular", "trip_headsign": "Sommet", "direction_id": "0" } | |
| 98 | + ], | |
| 99 | + "stopTimes": [ | |
| 100 | + { "trip_id": "trip-bus-preserved", "stop_id": "IDFM:ART-BUS-1", "stop_sequence": "1", "pickup_type": "0" }, | |
| 101 | + { "trip_id": "trip-bus-preserved", "stop_id": "IDFM:TERM-BUS-1", "stop_sequence": "2", "pickup_type": "1" }, | |
| 102 | + { "trip_id": "trip-bus-homonym", "stop_id": "IDFM:ART-BUS-2", "stop_sequence": "1", "pickup_type": "0" }, | |
| 103 | + { "trip_id": "trip-bus-homonym", "stop_id": "IDFM:TERM-BUS-2", "stop_sequence": "2", "pickup_type": "1" }, | |
| 104 | + { "trip_id": "trip-bus-no-exact-perimeter", "stop_id": "IDFM:ART-BUS-MISMATCH", "stop_sequence": "1", "pickup_type": "0" }, | |
| 105 | + { "trip_id": "trip-bus-no-exact-perimeter", "stop_id": "IDFM:TERM-BUS-MISMATCH", "stop_sequence": "2", "pickup_type": "1" }, | |
| 106 | + { "trip_id": "trip-metro-east", "stop_id": "IDFM:ART-METRO-EAST", "stop_sequence": "1", "pickup_type": "0" }, | |
| 107 | + { "trip_id": "trip-metro-east", "stop_id": "IDFM:TERM-METRO-SOUTH", "stop_sequence": "2", "pickup_type": "1" }, | |
| 108 | + { "trip_id": "trip-metro-west", "stop_id": "IDFM:ART-METRO-WEST", "stop_sequence": "1", "pickup_type": "0", "revisions": ["fixture-2026-08-a"] }, | |
| 109 | + { "trip_id": "trip-metro-west", "stop_id": "IDFM:TERM-METRO-NORTH", "stop_sequence": "2", "pickup_type": "1", "revisions": ["fixture-2026-08-a"] }, | |
| 110 | + { "trip_id": "trip-tram", "stop_id": "IDFM:ART-TRAM", "stop_sequence": "1", "pickup_type": "0" }, | |
| 111 | + { "trip_id": "trip-tram", "stop_id": "IDFM:TERM-TRAM", "stop_sequence": "2", "pickup_type": "1" }, | |
| 112 | + { "trip_id": "trip-rer-east", "stop_id": "IDFM:ART-RER-EAST", "stop_sequence": "1", "pickup_type": "0" }, | |
| 113 | + { "trip_id": "trip-rer-east", "stop_id": "IDFM:TERM-RER-EAST", "stop_sequence": "2", "pickup_type": "1" }, | |
| 114 | + { "trip_id": "trip-rer-west", "stop_id": "IDFM:ART-RER-WEST", "stop_sequence": "1", "pickup_type": "0" }, | |
| 115 | + { "trip_id": "trip-rer-west", "stop_id": "IDFM:TERM-RER-WEST", "stop_sequence": "2", "pickup_type": "1" }, | |
| 116 | + { "trip_id": "trip-transilien", "stop_id": "IDFM:ART-TRANSILIEN", "stop_sequence": "1", "pickup_type": "0" }, | |
| 117 | + { "trip_id": "trip-transilien", "stop_id": "IDFM:TERM-TRANSILIEN", "stop_sequence": "2", "pickup_type": "1" }, | |
| 118 | + { "trip_id": "trip-funicular", "stop_id": "IDFM:ART-FUNICULAR", "stop_sequence": "1", "pickup_type": "0" }, | |
| 119 | + { "trip_id": "trip-funicular", "stop_id": "IDFM:TERM-FUNICULAR", "stop_sequence": "2", "pickup_type": "1" } | |
| 120 | + ], | |
| 121 | + "objectCodes": [ | |
| 122 | + { "object_type": "StopPoint", "object_id": "IDFM:ART-BUS-1", "object_system": "SIRI", "object_code": "STIF:StopPoint:Q:BUS-1:" }, | |
| 123 | + { "object_type": "StopPoint", "object_id": "IDFM:TERM-BUS-1", "object_system": "source", "object_code": "DEST:BUS-1" }, | |
| 124 | + { "object_type": "StopPoint", "object_id": "IDFM:ART-BUS-2", "object_system": "SIRI", "object_code": "STIF:StopPoint:Q:BUS-2:" }, | |
| 125 | + { "object_type": "StopPoint", "object_id": "IDFM:TERM-BUS-2", "object_system": "source", "object_code": "DEST:BUS-2" }, | |
| 126 | + { "object_type": "StopPoint", "object_id": "IDFM:ART-BUS-MISMATCH", "object_system": "SIRI", "object_code": "STIF:StopPoint:Q:BUS-MISSING:" }, | |
| 127 | + { "object_type": "StopPoint", "object_id": "IDFM:TERM-BUS-MISMATCH", "object_system": "source", "object_code": "DEST:BUS-MISMATCH" }, | |
| 128 | + { "object_type": "StopPoint", "object_id": "IDFM:ART-METRO-EAST", "object_system": "SIRI", "object_code": "STIF:StopPoint:Q:METRO-EAST:" }, | |
| 129 | + { "object_type": "StopPoint", "object_id": "IDFM:TERM-METRO-SOUTH", "object_system": "source", "object_code": "DEST:METRO-SOUTH" }, | |
| 130 | + { "object_type": "StopPoint", "object_id": "IDFM:ART-METRO-WEST", "object_system": "SIRI", "object_code": "STIF:StopPoint:Q:METRO-WEST:", "revisions": ["fixture-2026-08-a"] }, | |
| 131 | + { "object_type": "StopPoint", "object_id": "IDFM:TERM-METRO-NORTH", "object_system": "source", "object_code": "DEST:METRO-NORTH", "revisions": ["fixture-2026-08-a"] }, | |
| 132 | + { "object_type": "StopPoint", "object_id": "IDFM:ART-TRAM", "object_system": "SIRI", "object_code": "STIF:StopPoint:Q:TRAM:" }, | |
| 133 | + { "object_type": "StopPoint", "object_id": "IDFM:TERM-TRAM", "object_system": "source", "object_code": "DEST:TRAM" }, | |
| 134 | + { "object_type": "StopPoint", "object_id": "IDFM:ART-RER-EAST", "object_system": "SIRI", "object_code": "STIF:StopArea:SP:RER-CHAT:" }, | |
| 135 | + { "object_type": "StopPoint", "object_id": "IDFM:TERM-RER-EAST", "object_system": "source", "object_code": "DEST:RER-EAST" }, | |
| 136 | + { "object_type": "StopPoint", "object_id": "IDFM:ART-RER-WEST", "object_system": "SIRI", "object_code": "STIF:StopArea:SP:RER-CHAT:" }, | |
| 137 | + { "object_type": "StopPoint", "object_id": "IDFM:TERM-RER-WEST", "object_system": "source", "object_code": "DEST:RER-WEST" }, | |
| 138 | + { "object_type": "StopPoint", "object_id": "IDFM:ART-TRANSILIEN", "object_system": "SIRI", "object_code": "STIF:StopArea:SP:TRANSILIEN-STL:" }, | |
| 139 | + { "object_type": "StopPoint", "object_id": "IDFM:TERM-TRANSILIEN", "object_system": "source", "object_code": "DEST:TRANSILIEN" }, | |
| 140 | + { "object_type": "StopPoint", "object_id": "IDFM:ART-FUNICULAR", "object_system": "SIRI", "object_code": "STIF:StopPoint:Q:FUNICULAR:" }, | |
| 141 | + { "object_type": "StopPoint", "object_id": "IDFM:TERM-FUNICULAR", "object_system": "source", "object_code": "DEST:FUNICULAR" } | |
| 142 | + ], | |
| 143 | + "arretsLignes": [ | |
| 144 | + { "id": "IDFM:C100", "stop_id": "IDFM:ART-BUS-1" }, | |
| 145 | + { "id": "IDFM:C100", "stop_id": "IDFM:ART-BUS-2" }, | |
| 146 | + { "id": "IDFM:C100", "stop_id": "IDFM:ART-BUS-MISMATCH" }, | |
| 147 | + { "id": "IDFM:C200", "stop_id": "IDFM:ART-METRO-EAST" }, | |
| 148 | + { "id": "IDFM:C200", "stop_id": "IDFM:ART-METRO-WEST" }, | |
| 149 | + { "id": "IDFM:C300", "stop_id": "IDFM:ART-TRAM" }, | |
| 150 | + { "id": "IDFM:C400", "stop_id": "IDFM:ART-RER-EAST" }, | |
| 151 | + { "id": "IDFM:C400", "stop_id": "IDFM:ART-RER-WEST" }, | |
| 152 | + { "id": "IDFM:C500", "stop_id": "IDFM:ART-TRANSILIEN" }, | |
| 153 | + { "id": "IDFM:C600", "stop_id": "IDFM:ART-FUNICULAR" } | |
| 154 | + ], | |
| 155 | + "perimeter": [ | |
| 156 | + { "line": "IDFM:C100", "name_line": "42", "ns2_stoppointref": "STIF:StopPoint:Q:BUS-1:", "ns2_stopname": "Gare du Nord", "operatorname": "Fixture bus" }, | |
| 157 | + { "line": "IDFM:C100", "name_line": "42", "ns2_stoppointref": "STIF:StopPoint:Q:BUS-2:", "ns2_stopname": "Gare du Nord", "operatorname": "Fixture bus" }, | |
| 158 | + { "line": "IDFM:C100", "name_line": "42", "ns2_stoppointref": "STIF:StopPoint:Q:BUS-OTHER:", "ns2_stopname": "Mismatch", "operatorname": "Fixture bus" }, | |
| 159 | + { "line": "IDFM:C200", "name_line": "4", "ns2_stoppointref": "STIF:StopPoint:Q:METRO-EAST:", "ns2_stopname": "Châtelet", "operatorname": "Fixture metro" }, | |
| 160 | + { "line": "IDFM:C200", "name_line": "4", "ns2_stoppointref": "STIF:StopPoint:Q:METRO-WEST:", "ns2_stopname": "Châtelet", "operatorname": "Fixture metro", "revisions": ["fixture-2026-08-a"] }, | |
| 161 | + { "line": "IDFM:C300", "name_line": "T3a", "ns2_stoppointref": "STIF:StopPoint:Q:TRAM:", "ns2_stopname": "Porte de Versailles", "operatorname": "Fixture tram" }, | |
| 162 | + { "line": "IDFM:C400", "name_line": "A", "ns2_stoppointref": "STIF:StopArea:SP:RER-CHAT:", "ns2_stopname": "Châtelet - Les Halles", "operatorname": "Fixture rail" }, | |
| 163 | + { "line": "IDFM:C500", "name_line": "L", "ns2_stoppointref": "STIF:StopArea:SP:TRANSILIEN-STL:", "ns2_stopname": "Saint-Lazare", "operatorname": "Fixture rail", "revisions": ["fixture-2026-08-a", "fixture-2026-08-b"] }, | |
| 164 | + { "line": "IDFM:C600", "name_line": "F", "ns2_stoppointref": "STIF:StopPoint:Q:FUNICULAR:", "ns2_stopname": "Montmartre", "operatorname": "Fixture funicular" } | |
| 165 | + ] | |
| 166 | + } | |
| 167 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,167 @@ | |||
| 1 | +{ | ||
| 2 | + "schemaVersion": 1, | ||
| 3 | + "fixtureVersion": "idfm-catalog-v1", | ||
| 4 | + "recordedAt": "2026-08-30T00:00:00.000Z", | ||
| 5 | + "dataClassification": "Schema-faithful deterministic reduction of official source layouts; fixture-only identifiers, no credential and no personal departure response.", | ||
| 6 | + "attribution": [ | ||
| 7 | + { "dataset": "offre-horaires-tc-gtfs-idfm", "url": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/offre-horaires-tc-gtfs-idfm", "retrievedAt": "2026-08-30T00:00:00.000Z", "license": "Licence Mobilité" }, | ||
| 8 | + { "dataset": "perimetre-des-donnees-tr-disponibles-plateforme-idfm", "url": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/perimetre-des-donnees-tr-disponibles-plateforme-idfm", "retrievedAt": "2026-08-30T00:00:00.000Z", "license": "Licence Mobilité" }, | ||
| 9 | + { "dataset": "arrets", "url": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/arrets", "retrievedAt": "2026-08-30T00:00:00.000Z", "license": "Licence Ouverte 2.0" }, | ||
| 10 | + { "dataset": "zones-d-arrets", "url": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/zones-d-arrets", "retrievedAt": "2026-08-30T00:00:00.000Z", "license": "Licence Ouverte 2.0" }, | ||
| 11 | + { "dataset": "relations", "url": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/relations", "retrievedAt": "2026-08-30T00:00:00.000Z", "license": "Licence Ouverte 2.0" }, | ||
| 12 | + { "dataset": "referentiel-des-lignes", "url": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes", "retrievedAt": "2026-08-30T00:00:00.000Z", "license": "ODbL 1.0" }, | ||
| 13 | + { "dataset": "arrets-lignes", "url": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/arrets-lignes", "retrievedAt": "2026-08-30T00:00:00.000Z", "license": "ODbL 1.0" } | ||
| 14 | + ], | ||
| 15 | + "revisions": [ | ||
| 16 | + { "id": "fixture-2026-08-a", "preservedTripId": "trip-bus-preserved", "removedTripId": "trip-metro-west" }, | ||
| 17 | + { "id": "fixture-2026-08-b", "preservedTripId": "trip-bus-preserved", "removedTripId": "trip-metro-west" }, | ||
| 18 | + { "id": "fixture-invalid-no-transilien", "invalidReason": "No exact TRANSILIEN perimeter pair; five-mode validation rejects the candidate" } | ||
| 19 | + ], | ||
| 20 | + "sources": { | ||
| 21 | + "agency": [ | ||
| 22 | + { "agency_name": "Fixture IDFM operator", "agency_url": "https://www.iledefrance-mobilites.fr/", "agency_timezone": "Europe/Paris" } | ||
| 23 | + ], | ||
| 24 | + "lines": [ | ||
| 25 | + { "id_line": "C100", "name_line": "Bus 42", "shortname_line": "42", "transportmode": "bus", "transportsubmode": "", "status": "active" }, | ||
| 26 | + { "id_line": "C200", "name_line": "Métro 4", "shortname_line": "4", "transportmode": "metro", "transportsubmode": "", "status": "active" }, | ||
| 27 | + { "id_line": "C300", "name_line": "Tram T3a", "shortname_line": "T3a", "transportmode": "tram", "transportsubmode": "", "status": "active" }, | ||
| 28 | + { "id_line": "C400", "name_line": "RER A", "shortname_line": "A", "transportmode": "rail", "transportsubmode": "local", "status": "active" }, | ||
| 29 | + { "id_line": "C500", "name_line": "Transilien L", "shortname_line": "L", "transportmode": "rail", "transportsubmode": "suburbanRailway", "status": "active" }, | ||
| 30 | + { "id_line": "C600", "name_line": "Funiculaire", "shortname_line": "F", "transportmode": "funicular", "transportsubmode": "", "status": "active" } | ||
| 31 | + ], | ||
| 32 | + "routes": [ | ||
| 33 | + { "route_id": "IDFM:C100", "route_short_name": "42", "route_long_name": "Bus 42" }, | ||
| 34 | + { "route_id": "IDFM:C200", "route_short_name": "4", "route_long_name": "Métro 4" }, | ||
| 35 | + { "route_id": "IDFM:C300", "route_short_name": "T3a", "route_long_name": "Tram T3a" }, | ||
| 36 | + { "route_id": "IDFM:C400", "route_short_name": "A", "route_long_name": "RER A" }, | ||
| 37 | + { "route_id": "IDFM:C500", "route_short_name": "L", "route_long_name": "Transilien L" }, | ||
| 38 | + { "route_id": "IDFM:C600", "route_short_name": "F", "route_long_name": "Funiculaire" } | ||
| 39 | + ], | ||
| 40 | + "zones": [ | ||
| 41 | + { "zdaid": "ZDA-BUS-1", "zdaname": "Gare du Nord", "zdatown": "Paris" }, | ||
| 42 | + { "zdaid": "ZDA-BUS-2", "zdaname": "Gare du Nord", "zdatown": "Saint-Denis" }, | ||
| 43 | + { "zdaid": "ZDA-METRO", "zdaname": "Châtelet", "zdatown": "Paris" }, | ||
| 44 | + { "zdaid": "ZDA-TRAM", "zdaname": "Porte de Versailles", "zdatown": "Paris" }, | ||
| 45 | + { "zdaid": "ZDA-RER", "zdaname": "Châtelet - Les Halles", "zdatown": "Paris" }, | ||
| 46 | + { "zdaid": "ZDA-TRANSILIEN", "zdaname": "Saint-Lazare", "zdatown": "Paris" }, | ||
| 47 | + { "zdaid": "ZDA-FUNICULAR", "zdaname": "Montmartre", "zdatown": "Paris" } | ||
| 48 | + ], | ||
| 49 | + "arrets": [ | ||
| 50 | + { "arrid": "ARR-BUS-1", "zdaid": "ZDA-BUS-1" }, | ||
| 51 | + { "arrid": "ARR-BUS-2", "zdaid": "ZDA-BUS-2" }, | ||
| 52 | + { "arrid": "ARR-METRO-EAST", "zdaid": "ZDA-METRO" }, | ||
| 53 | + { "arrid": "ARR-METRO-WEST", "zdaid": "ZDA-METRO" }, | ||
| 54 | + { "arrid": "ARR-TRAM", "zdaid": "ZDA-TRAM" }, | ||
| 55 | + { "arrid": "ARR-FUNICULAR", "zdaid": "ZDA-FUNICULAR" } | ||
| 56 | + ], | ||
| 57 | + "relations": [ | ||
| 58 | + { "zdaid": "ZDA-BUS-1", "arrid": "ARR-BUS-1", "artid": "ART-BUS-1" }, | ||
| 59 | + { "zdaid": "ZDA-BUS-2", "arrid": "ARR-BUS-2", "artid": "ART-BUS-2" }, | ||
| 60 | + { "zdaid": "ZDA-METRO", "arrid": "ARR-METRO-EAST", "artid": "ART-METRO-EAST" }, | ||
| 61 | + { "zdaid": "ZDA-METRO", "arrid": "ARR-METRO-WEST", "artid": "ART-METRO-WEST" }, | ||
| 62 | + { "zdaid": "ZDA-TRAM", "arrid": "ARR-TRAM", "artid": "ART-TRAM" }, | ||
| 63 | + { "zdaid": "ZDA-FUNICULAR", "arrid": "ARR-FUNICULAR", "artid": "ART-FUNICULAR" } | ||
| 64 | + ], | ||
| 65 | + "stops": [ | ||
| 66 | + { "stop_id": "IDFM:ART-BUS-1", "stop_name": "Gare du Nord quai bus", "parent_station": "IDFM:ARR-BUS-1" }, | ||
| 67 | + { "stop_id": "IDFM:TERM-BUS-1", "stop_name": "Hôpital Européen", "parent_station": "" }, | ||
| 68 | + { "stop_id": "IDFM:ART-BUS-2", "stop_name": "Gare du Nord annexe", "parent_station": "IDFM:ARR-BUS-2" }, | ||
| 69 | + { "stop_id": "IDFM:TERM-BUS-2", "stop_name": "Mairie", "parent_station": "" }, | ||
| 70 | + { "stop_id": "IDFM:ART-BUS-MISMATCH", "stop_name": "Mismatch", "parent_station": "IDFM:ARR-BUS-1" }, | ||
| 71 | + { "stop_id": "IDFM:TERM-BUS-MISMATCH", "stop_name": "Wrong perimeter destination", "parent_station": "" }, | ||
| 72 | + { "stop_id": "IDFM:ART-METRO-EAST", "stop_name": "Châtelet sud", "parent_station": "IDFM:ARR-METRO-EAST" }, | ||
| 73 | + { "stop_id": "IDFM:TERM-METRO-SOUTH", "stop_name": "Bagneux - Lucie Aubrac", "parent_station": "" }, | ||
| 74 | + { "stop_id": "IDFM:ART-METRO-WEST", "stop_name": "Châtelet nord", "parent_station": "IDFM:ARR-METRO-WEST" }, | ||
| 75 | + { "stop_id": "IDFM:TERM-METRO-NORTH", "stop_name": "Porte de Clignancourt", "parent_station": "" }, | ||
| 76 | + { "stop_id": "IDFM:ART-TRAM", "stop_name": "Porte de Versailles quai tram", "parent_station": "IDFM:ARR-TRAM" }, | ||
| 77 | + { "stop_id": "IDFM:TERM-TRAM", "stop_name": "Pont du Garigliano", "parent_station": "" }, | ||
| 78 | + { "stop_id": "IDFM:ART-RER-EAST", "stop_name": "Châtelet RER est", "parent_station": "IDFM:monomodalStopPlace:ZDA-RER" }, | ||
| 79 | + { "stop_id": "IDFM:TERM-RER-EAST", "stop_name": "Marne-la-Vallée Chessy", "parent_station": "" }, | ||
| 80 | + { "stop_id": "IDFM:ART-RER-WEST", "stop_name": "Châtelet RER ouest", "parent_station": "IDFM:monomodalStopPlace:ZDA-RER" }, | ||
| 81 | + { "stop_id": "IDFM:TERM-RER-WEST", "stop_name": "Saint-Germain-en-Laye", "parent_station": "" }, | ||
| 82 | + { "stop_id": "IDFM:ART-TRANSILIEN", "stop_name": "Saint-Lazare quai L", "parent_station": "IDFM:monomodalStopPlace:ZDA-TRANSILIEN" }, | ||
| 83 | + { "stop_id": "IDFM:TERM-TRANSILIEN", "stop_name": "Versailles Rive Droite", "parent_station": "" }, | ||
| 84 | + { "stop_id": "IDFM:ART-FUNICULAR", "stop_name": "Funiculaire bas", "parent_station": "IDFM:ARR-FUNICULAR" }, | ||
| 85 | + { "stop_id": "IDFM:TERM-FUNICULAR", "stop_name": "Funiculaire haut", "parent_station": "" } | ||
| 86 | + ], | ||
| 87 | + "trips": [ | ||
| 88 | + { "route_id": "IDFM:C100", "trip_id": "trip-bus-preserved", "trip_headsign": "Hôpital Européen", "direction_id": "0" }, | ||
| 89 | + { "route_id": "IDFM:C100", "trip_id": "trip-bus-homonym", "trip_headsign": "Mairie", "direction_id": "1" }, | ||
| 90 | + { "route_id": "IDFM:C100", "trip_id": "trip-bus-no-exact-perimeter", "trip_headsign": "Wrong perimeter destination", "direction_id": "0" }, | ||
| 91 | + { "route_id": "IDFM:C200", "trip_id": "trip-metro-east", "trip_headsign": "Bagneux - Lucie Aubrac", "direction_id": "0" }, | ||
| 92 | + { "route_id": "IDFM:C200", "trip_id": "trip-metro-west", "trip_headsign": "Porte de Clignancourt", "direction_id": "1", "revisions": ["fixture-2026-08-a"] }, | ||
| 93 | + { "route_id": "IDFM:C300", "trip_id": "trip-tram", "trip_headsign": "Pont du Garigliano", "direction_id": "0" }, | ||
| 94 | + { "route_id": "IDFM:C400", "trip_id": "trip-rer-east", "trip_headsign": "NORA", "direction_id": "0" }, | ||
| 95 | + { "route_id": "IDFM:C400", "trip_id": "trip-rer-west", "trip_headsign": "ZEBU", "direction_id": "1" }, | ||
| 96 | + { "route_id": "IDFM:C500", "trip_id": "trip-transilien", "trip_headsign": "MILA", "direction_id": "0" }, | ||
| 97 | + { "route_id": "IDFM:C600", "trip_id": "trip-funicular", "trip_headsign": "Sommet", "direction_id": "0" } | ||
| 98 | + ], | ||
| 99 | + "stopTimes": [ | ||
| 100 | + { "trip_id": "trip-bus-preserved", "stop_id": "IDFM:ART-BUS-1", "stop_sequence": "1", "pickup_type": "0" }, | ||
| 101 | + { "trip_id": "trip-bus-preserved", "stop_id": "IDFM:TERM-BUS-1", "stop_sequence": "2", "pickup_type": "1" }, | ||
| 102 | + { "trip_id": "trip-bus-homonym", "stop_id": "IDFM:ART-BUS-2", "stop_sequence": "1", "pickup_type": "0" }, | ||
| 103 | + { "trip_id": "trip-bus-homonym", "stop_id": "IDFM:TERM-BUS-2", "stop_sequence": "2", "pickup_type": "1" }, | ||
| 104 | + { "trip_id": "trip-bus-no-exact-perimeter", "stop_id": "IDFM:ART-BUS-MISMATCH", "stop_sequence": "1", "pickup_type": "0" }, | ||
| 105 | + { "trip_id": "trip-bus-no-exact-perimeter", "stop_id": "IDFM:TERM-BUS-MISMATCH", "stop_sequence": "2", "pickup_type": "1" }, | ||
| 106 | + { "trip_id": "trip-metro-east", "stop_id": "IDFM:ART-METRO-EAST", "stop_sequence": "1", "pickup_type": "0" }, | ||
| 107 | + { "trip_id": "trip-metro-east", "stop_id": "IDFM:TERM-METRO-SOUTH", "stop_sequence": "2", "pickup_type": "1" }, | ||
| 108 | + { "trip_id": "trip-metro-west", "stop_id": "IDFM:ART-METRO-WEST", "stop_sequence": "1", "pickup_type": "0", "revisions": ["fixture-2026-08-a"] }, | ||
| 109 | + { "trip_id": "trip-metro-west", "stop_id": "IDFM:TERM-METRO-NORTH", "stop_sequence": "2", "pickup_type": "1", "revisions": ["fixture-2026-08-a"] }, | ||
| 110 | + { "trip_id": "trip-tram", "stop_id": "IDFM:ART-TRAM", "stop_sequence": "1", "pickup_type": "0" }, | ||
| 111 | + { "trip_id": "trip-tram", "stop_id": "IDFM:TERM-TRAM", "stop_sequence": "2", "pickup_type": "1" }, | ||
| 112 | + { "trip_id": "trip-rer-east", "stop_id": "IDFM:ART-RER-EAST", "stop_sequence": "1", "pickup_type": "0" }, | ||
| 113 | + { "trip_id": "trip-rer-east", "stop_id": "IDFM:TERM-RER-EAST", "stop_sequence": "2", "pickup_type": "1" }, | ||
| 114 | + { "trip_id": "trip-rer-west", "stop_id": "IDFM:ART-RER-WEST", "stop_sequence": "1", "pickup_type": "0" }, | ||
| 115 | + { "trip_id": "trip-rer-west", "stop_id": "IDFM:TERM-RER-WEST", "stop_sequence": "2", "pickup_type": "1" }, | ||
| 116 | + { "trip_id": "trip-transilien", "stop_id": "IDFM:ART-TRANSILIEN", "stop_sequence": "1", "pickup_type": "0" }, | ||
| 117 | + { "trip_id": "trip-transilien", "stop_id": "IDFM:TERM-TRANSILIEN", "stop_sequence": "2", "pickup_type": "1" }, | ||
| 118 | + { "trip_id": "trip-funicular", "stop_id": "IDFM:ART-FUNICULAR", "stop_sequence": "1", "pickup_type": "0" }, | ||
| 119 | + { "trip_id": "trip-funicular", "stop_id": "IDFM:TERM-FUNICULAR", "stop_sequence": "2", "pickup_type": "1" } | ||
| 120 | + ], | ||
| 121 | + "objectCodes": [ | ||
| 122 | + { "object_type": "StopPoint", "object_id": "IDFM:ART-BUS-1", "object_system": "SIRI", "object_code": "STIF:StopPoint:Q:BUS-1:" }, | ||
| 123 | + { "object_type": "StopPoint", "object_id": "IDFM:TERM-BUS-1", "object_system": "source", "object_code": "DEST:BUS-1" }, | ||
| 124 | + { "object_type": "StopPoint", "object_id": "IDFM:ART-BUS-2", "object_system": "SIRI", "object_code": "STIF:StopPoint:Q:BUS-2:" }, | ||
| 125 | + { "object_type": "StopPoint", "object_id": "IDFM:TERM-BUS-2", "object_system": "source", "object_code": "DEST:BUS-2" }, | ||
| 126 | + { "object_type": "StopPoint", "object_id": "IDFM:ART-BUS-MISMATCH", "object_system": "SIRI", "object_code": "STIF:StopPoint:Q:BUS-MISSING:" }, | ||
| 127 | + { "object_type": "StopPoint", "object_id": "IDFM:TERM-BUS-MISMATCH", "object_system": "source", "object_code": "DEST:BUS-MISMATCH" }, | ||
| 128 | + { "object_type": "StopPoint", "object_id": "IDFM:ART-METRO-EAST", "object_system": "SIRI", "object_code": "STIF:StopPoint:Q:METRO-EAST:" }, | ||
| 129 | + { "object_type": "StopPoint", "object_id": "IDFM:TERM-METRO-SOUTH", "object_system": "source", "object_code": "DEST:METRO-SOUTH" }, | ||
| 130 | + { "object_type": "StopPoint", "object_id": "IDFM:ART-METRO-WEST", "object_system": "SIRI", "object_code": "STIF:StopPoint:Q:METRO-WEST:", "revisions": ["fixture-2026-08-a"] }, | ||
| 131 | + { "object_type": "StopPoint", "object_id": "IDFM:TERM-METRO-NORTH", "object_system": "source", "object_code": "DEST:METRO-NORTH", "revisions": ["fixture-2026-08-a"] }, | ||
| 132 | + { "object_type": "StopPoint", "object_id": "IDFM:ART-TRAM", "object_system": "SIRI", "object_code": "STIF:StopPoint:Q:TRAM:" }, | ||
| 133 | + { "object_type": "StopPoint", "object_id": "IDFM:TERM-TRAM", "object_system": "source", "object_code": "DEST:TRAM" }, | ||
| 134 | + { "object_type": "StopPoint", "object_id": "IDFM:ART-RER-EAST", "object_system": "SIRI", "object_code": "STIF:StopArea:SP:RER-CHAT:" }, | ||
| 135 | + { "object_type": "StopPoint", "object_id": "IDFM:TERM-RER-EAST", "object_system": "source", "object_code": "DEST:RER-EAST" }, | ||
| 136 | + { "object_type": "StopPoint", "object_id": "IDFM:ART-RER-WEST", "object_system": "SIRI", "object_code": "STIF:StopArea:SP:RER-CHAT:" }, | ||
| 137 | + { "object_type": "StopPoint", "object_id": "IDFM:TERM-RER-WEST", "object_system": "source", "object_code": "DEST:RER-WEST" }, | ||
| 138 | + { "object_type": "StopPoint", "object_id": "IDFM:ART-TRANSILIEN", "object_system": "SIRI", "object_code": "STIF:StopArea:SP:TRANSILIEN-STL:" }, | ||
| 139 | + { "object_type": "StopPoint", "object_id": "IDFM:TERM-TRANSILIEN", "object_system": "source", "object_code": "DEST:TRANSILIEN" }, | ||
| 140 | + { "object_type": "StopPoint", "object_id": "IDFM:ART-FUNICULAR", "object_system": "SIRI", "object_code": "STIF:StopPoint:Q:FUNICULAR:" }, | ||
| 141 | + { "object_type": "StopPoint", "object_id": "IDFM:TERM-FUNICULAR", "object_system": "source", "object_code": "DEST:FUNICULAR" } | ||
| 142 | + ], | ||
| 143 | + "arretsLignes": [ | ||
| 144 | + { "id": "IDFM:C100", "stop_id": "IDFM:ART-BUS-1" }, | ||
| 145 | + { "id": "IDFM:C100", "stop_id": "IDFM:ART-BUS-2" }, | ||
| 146 | + { "id": "IDFM:C100", "stop_id": "IDFM:ART-BUS-MISMATCH" }, | ||
| 147 | + { "id": "IDFM:C200", "stop_id": "IDFM:ART-METRO-EAST" }, | ||
| 148 | + { "id": "IDFM:C200", "stop_id": "IDFM:ART-METRO-WEST" }, | ||
| 149 | + { "id": "IDFM:C300", "stop_id": "IDFM:ART-TRAM" }, | ||
| 150 | + { "id": "IDFM:C400", "stop_id": "IDFM:ART-RER-EAST" }, | ||
| 151 | + { "id": "IDFM:C400", "stop_id": "IDFM:ART-RER-WEST" }, | ||
| 152 | + { "id": "IDFM:C500", "stop_id": "IDFM:ART-TRANSILIEN" }, | ||
| 153 | + { "id": "IDFM:C600", "stop_id": "IDFM:ART-FUNICULAR" } | ||
| 154 | + ], | ||
| 155 | + "perimeter": [ | ||
| 156 | + { "line": "IDFM:C100", "name_line": "42", "ns2_stoppointref": "STIF:StopPoint:Q:BUS-1:", "ns2_stopname": "Gare du Nord", "operatorname": "Fixture bus" }, | ||
| 157 | + { "line": "IDFM:C100", "name_line": "42", "ns2_stoppointref": "STIF:StopPoint:Q:BUS-2:", "ns2_stopname": "Gare du Nord", "operatorname": "Fixture bus" }, | ||
| 158 | + { "line": "IDFM:C100", "name_line": "42", "ns2_stoppointref": "STIF:StopPoint:Q:BUS-OTHER:", "ns2_stopname": "Mismatch", "operatorname": "Fixture bus" }, | ||
| 159 | + { "line": "IDFM:C200", "name_line": "4", "ns2_stoppointref": "STIF:StopPoint:Q:METRO-EAST:", "ns2_stopname": "Châtelet", "operatorname": "Fixture metro" }, | ||
| 160 | + { "line": "IDFM:C200", "name_line": "4", "ns2_stoppointref": "STIF:StopPoint:Q:METRO-WEST:", "ns2_stopname": "Châtelet", "operatorname": "Fixture metro", "revisions": ["fixture-2026-08-a"] }, | ||
| 161 | + { "line": "IDFM:C300", "name_line": "T3a", "ns2_stoppointref": "STIF:StopPoint:Q:TRAM:", "ns2_stopname": "Porte de Versailles", "operatorname": "Fixture tram" }, | ||
| 162 | + { "line": "IDFM:C400", "name_line": "A", "ns2_stoppointref": "STIF:StopArea:SP:RER-CHAT:", "ns2_stopname": "Châtelet - Les Halles", "operatorname": "Fixture rail" }, | ||
| 163 | + { "line": "IDFM:C500", "name_line": "L", "ns2_stoppointref": "STIF:StopArea:SP:TRANSILIEN-STL:", "ns2_stopname": "Saint-Lazare", "operatorname": "Fixture rail", "revisions": ["fixture-2026-08-a", "fixture-2026-08-b"] }, | ||
| 164 | + { "line": "IDFM:C600", "name_line": "F", "ns2_stoppointref": "STIF:StopPoint:Q:FUNICULAR:", "ns2_stopname": "Montmartre", "operatorname": "Fixture funicular" } | ||
| 165 | + ] | ||
| 166 | + } | ||
| 167 | +} | ||
added
package-lock.json +78 -0 | new file mode 100644 | ||
| @@ -0,0 +1,78 @@ | ||
| 1 | +{ | |
| 2 | + "name": "lapin-fute", | |
| 3 | + "version": "0.1.0", | |
| 4 | + "lockfileVersion": 3, | |
| 5 | + "requires": true, | |
| 6 | + "packages": { | |
| 7 | + "": { | |
| 8 | + "name": "lapin-fute", | |
| 9 | + "version": "0.1.0", | |
| 10 | + "workspaces": [ | |
| 11 | + "packages/*" | |
| 12 | + ], | |
| 13 | + "engines": { | |
| 14 | + "node": "24.18.0" | |
| 15 | + } | |
| 16 | + }, | |
| 17 | + "node_modules/@lapin-fute/backend": { | |
| 18 | + "resolved": "packages/backend", | |
| 19 | + "link": true | |
| 20 | + }, | |
| 21 | + "node_modules/@lapin-fute/companion": { | |
| 22 | + "resolved": "packages/companion", | |
| 23 | + "link": true | |
| 24 | + }, | |
| 25 | + "node_modules/@lapin-fute/config-page": { | |
| 26 | + "resolved": "packages/config-page", | |
| 27 | + "link": true | |
| 28 | + }, | |
| 29 | + "node_modules/csv-parse": { | |
| 30 | + "version": "7.0.2", | |
| 31 | + "resolved": "https://registry.npmjs.org/csv-parse/-/csv-parse-7.0.2.tgz", | |
| 32 | + "integrity": "sha512-uKZghv9UmPkMVLYy//KZ9HFAIJsl7wkhoEdIL0+rhuSY9pZQlhaeGEDPIe+/w7eh81MOql8Q/9+inAGWG6ZHYA==", | |
| 33 | + "license": "MIT" | |
| 34 | + }, | |
| 35 | + "node_modules/lapin-fute-watch": { | |
| 36 | + "resolved": "packages/watch", | |
| 37 | + "link": true | |
| 38 | + }, | |
| 39 | + "node_modules/pend": { | |
| 40 | + "version": "1.2.0", | |
| 41 | + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", | |
| 42 | + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", | |
| 43 | + "license": "MIT" | |
| 44 | + }, | |
| 45 | + "node_modules/yauzl": { | |
| 46 | + "version": "3.4.0", | |
| 47 | + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-3.4.0.tgz", | |
| 48 | + "integrity": "sha512-jIH9yLR9wqr0wOS0TpBvo/g/2UgZH5qePVbjgRliiF0BYvOZyaBknKsF+x9Iht0O6sqgnB93rCICdOZFecJuDw==", | |
| 49 | + "license": "MIT", | |
| 50 | + "dependencies": { | |
| 51 | + "pend": "~1.2.0" | |
| 52 | + }, | |
| 53 | + "engines": { | |
| 54 | + "node": ">=12" | |
| 55 | + } | |
| 56 | + }, | |
| 57 | + "packages/backend": { | |
| 58 | + "name": "@lapin-fute/backend", | |
| 59 | + "version": "0.1.0", | |
| 60 | + "dependencies": { | |
| 61 | + "csv-parse": "7.0.2", | |
| 62 | + "yauzl": "3.4.0" | |
| 63 | + } | |
| 64 | + }, | |
| 65 | + "packages/companion": { | |
| 66 | + "name": "@lapin-fute/companion", | |
| 67 | + "version": "0.1.0" | |
| 68 | + }, | |
| 69 | + "packages/config-page": { | |
| 70 | + "name": "@lapin-fute/config-page", | |
| 71 | + "version": "0.1.0" | |
| 72 | + }, | |
| 73 | + "packages/watch": { | |
| 74 | + "name": "lapin-fute-watch", | |
| 75 | + "version": "0.1.0" | |
| 76 | + } | |
| 77 | + } | |
| 78 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,78 @@ | |||
| 1 | +{ | ||
| 2 | + "name": "lapin-fute", | ||
| 3 | + "version": "0.1.0", | ||
| 4 | + "lockfileVersion": 3, | ||
| 5 | + "requires": true, | ||
| 6 | + "packages": { | ||
| 7 | + "": { | ||
| 8 | + "name": "lapin-fute", | ||
| 9 | + "version": "0.1.0", | ||
| 10 | + "workspaces": [ | ||
| 11 | + "packages/*" | ||
| 12 | + ], | ||
| 13 | + "engines": { | ||
| 14 | + "node": "24.18.0" | ||
| 15 | + } | ||
| 16 | + }, | ||
| 17 | + "node_modules/@lapin-fute/backend": { | ||
| 18 | + "resolved": "packages/backend", | ||
| 19 | + "link": true | ||
| 20 | + }, | ||
| 21 | + "node_modules/@lapin-fute/companion": { | ||
| 22 | + "resolved": "packages/companion", | ||
| 23 | + "link": true | ||
| 24 | + }, | ||
| 25 | + "node_modules/@lapin-fute/config-page": { | ||
| 26 | + "resolved": "packages/config-page", | ||
| 27 | + "link": true | ||
| 28 | + }, | ||
| 29 | + "node_modules/csv-parse": { | ||
| 30 | + "version": "7.0.2", | ||
| 31 | + "resolved": "https://registry.npmjs.org/csv-parse/-/csv-parse-7.0.2.tgz", | ||
| 32 | + "integrity": "sha512-uKZghv9UmPkMVLYy//KZ9HFAIJsl7wkhoEdIL0+rhuSY9pZQlhaeGEDPIe+/w7eh81MOql8Q/9+inAGWG6ZHYA==", | ||
| 33 | + "license": "MIT" | ||
| 34 | + }, | ||
| 35 | + "node_modules/lapin-fute-watch": { | ||
| 36 | + "resolved": "packages/watch", | ||
| 37 | + "link": true | ||
| 38 | + }, | ||
| 39 | + "node_modules/pend": { | ||
| 40 | + "version": "1.2.0", | ||
| 41 | + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", | ||
| 42 | + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", | ||
| 43 | + "license": "MIT" | ||
| 44 | + }, | ||
| 45 | + "node_modules/yauzl": { | ||
| 46 | + "version": "3.4.0", | ||
| 47 | + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-3.4.0.tgz", | ||
| 48 | + "integrity": "sha512-jIH9yLR9wqr0wOS0TpBvo/g/2UgZH5qePVbjgRliiF0BYvOZyaBknKsF+x9Iht0O6sqgnB93rCICdOZFecJuDw==", | ||
| 49 | + "license": "MIT", | ||
| 50 | + "dependencies": { | ||
| 51 | + "pend": "~1.2.0" | ||
| 52 | + }, | ||
| 53 | + "engines": { | ||
| 54 | + "node": ">=12" | ||
| 55 | + } | ||
| 56 | + }, | ||
| 57 | + "packages/backend": { | ||
| 58 | + "name": "@lapin-fute/backend", | ||
| 59 | + "version": "0.1.0", | ||
| 60 | + "dependencies": { | ||
| 61 | + "csv-parse": "7.0.2", | ||
| 62 | + "yauzl": "3.4.0" | ||
| 63 | + } | ||
| 64 | + }, | ||
| 65 | + "packages/companion": { | ||
| 66 | + "name": "@lapin-fute/companion", | ||
| 67 | + "version": "0.1.0" | ||
| 68 | + }, | ||
| 69 | + "packages/config-page": { | ||
| 70 | + "name": "@lapin-fute/config-page", | ||
| 71 | + "version": "0.1.0" | ||
| 72 | + }, | ||
| 73 | + "packages/watch": { | ||
| 74 | + "name": "lapin-fute-watch", | ||
| 75 | + "version": "0.1.0" | ||
| 76 | + } | ||
| 77 | + } | ||
| 78 | +} | ||
modified
package.json +3 -0 | @@ -11,7 +11,10 @@ | ||
| 11 | 11 | }, |
| 12 | 12 | "scripts": { |
| 13 | 13 | "build": "node scripts/build.mjs", |
| 14 | + "catalog:probe": "node scripts/probe-catalog.mjs", | |
| 15 | + "catalog:refresh": "node scripts/refresh-catalog.mjs", | |
| 14 | 16 | "measure:radio": "node scripts/measure-radio.mjs", |
| 17 | + "measure:catalog": "node scripts/measure-catalog.mjs", | |
| 15 | 18 | "test": "node scripts/test.mjs", |
| 16 | 19 | "test:contracts": "node --test packages/contracts/test/contracts.test.ts", |
| 17 | 20 | "test:watch": "node --test packages/watch/test/*.test.js", |
| @@ -11,7 +11,10 @@ | |||
| 11 | }, | 11 | }, |
| 12 | "scripts": { | 12 | "scripts": { |
| 13 | "build": "node scripts/build.mjs", | 13 | "build": "node scripts/build.mjs", |
| 14 | + "catalog:probe": "node scripts/probe-catalog.mjs", | ||
| 15 | + "catalog:refresh": "node scripts/refresh-catalog.mjs", | ||
| 14 | "measure:radio": "node scripts/measure-radio.mjs", | 16 | "measure:radio": "node scripts/measure-radio.mjs", |
| 17 | + "measure:catalog": "node scripts/measure-catalog.mjs", | ||
| 15 | "test": "node scripts/test.mjs", | 18 | "test": "node scripts/test.mjs", |
| 16 | "test:contracts": "node --test packages/contracts/test/contracts.test.ts", | 19 | "test:contracts": "node --test packages/contracts/test/contracts.test.ts", |
| 17 | "test:watch": "node --test packages/watch/test/*.test.js", | 20 | "test:watch": "node --test packages/watch/test/*.test.js", |
modified
packages/backend/package.json +5 -1 | @@ -4,7 +4,11 @@ | ||
| 4 | 4 | "private": true, |
| 5 | 5 | "type": "module", |
| 6 | 6 | "exports": "./src/index.ts", |
| 7 | + "dependencies": { | |
| 8 | + "csv-parse": "7.0.2", | |
| 9 | + "yauzl": "3.4.0" | |
| 10 | + }, | |
| 7 | 11 | "scripts": { |
| 8 | - "test": "node --test test/backend.test.ts" | |
| 12 | + "test": "node --test test/*.test.ts" | |
| 9 | 13 | } |
| 10 | 14 | } |
| @@ -4,7 +4,11 @@ | |||
| 4 | "private": true, | 4 | "private": true, |
| 5 | "type": "module", | 5 | "type": "module", |
| 6 | "exports": "./src/index.ts", | 6 | "exports": "./src/index.ts", |
| 7 | + "dependencies": { | ||
| 8 | + "csv-parse": "7.0.2", | ||
| 9 | + "yauzl": "3.4.0" | ||
| 10 | + }, | ||
| 7 | "scripts": { | 11 | "scripts": { |
| 8 | - "test": "node --test test/backend.test.ts" | 12 | + "test": "node --test test/*.test.ts" |
| 9 | } | 13 | } |
| 10 | } | 14 | } |
added
packages/backend/src/catalog-import.ts +1462 -0 Large diff collapsed to keep the page fast — load it when you need it.
added
packages/backend/src/catalog.ts +686 -0 Large diff collapsed to keep the page fast — load it when you need it.
modified
packages/backend/src/index.ts +9 -0 | @@ -13,6 +13,15 @@ import { | ||
| 13 | 13 | type ErrorResult, |
| 14 | 14 | } from "../../contracts/src/index.ts"; |
| 15 | 15 | |
| 16 | +export { | |
| 17 | + CatalogManager, | |
| 18 | + SqliteCatalogReader, | |
| 19 | + handleCatalogRequest, | |
| 20 | + type CatalogHandlerResponse, | |
| 21 | + type CatalogReader, | |
| 22 | + type CatalogServiceResolution, | |
| 23 | +} from "./catalog.ts"; | |
| 24 | + | |
| 16 | 25 | export const REDACTED_SECRET = "[REDACTED]" as const; |
| 17 | 26 | |
| 18 | 27 | export const BACKEND_ERROR_CODE = { |
| @@ -13,6 +13,15 @@ import { | |||
| 13 | type ErrorResult, | 13 | type ErrorResult, |
| 14 | } from "../../contracts/src/index.ts"; | 14 | } from "../../contracts/src/index.ts"; |
| 15 | 15 | ||
| 16 | +export { | ||
| 17 | + CatalogManager, | ||
| 18 | + SqliteCatalogReader, | ||
| 19 | + handleCatalogRequest, | ||
| 20 | + type CatalogHandlerResponse, | ||
| 21 | + type CatalogReader, | ||
| 22 | + type CatalogServiceResolution, | ||
| 23 | +} from "./catalog.ts"; | ||
| 24 | + | ||
| 16 | export const REDACTED_SECRET = "[REDACTED]" as const; | 25 | export const REDACTED_SECRET = "[REDACTED]" as const; |
| 17 | 26 | ||
| 18 | export const BACKEND_ERROR_CODE = { | 27 | export const BACKEND_ERROR_CODE = { |
added
packages/backend/test/catalog-http.test.ts +158 -0 | new file mode 100644 | ||
| @@ -0,0 +1,158 @@ | ||
| 1 | +import test from "node:test"; | |
| 2 | +import assert from "node:assert/strict"; | |
| 3 | +import { mkdtempSync, rmSync } from "node:fs"; | |
| 4 | +import { tmpdir } from "node:os"; | |
| 5 | +import { join } from "node:path"; | |
| 6 | +import { DatabaseSync } from "node:sqlite"; | |
| 7 | +import { | |
| 8 | + CATALOG_ROUTE, | |
| 9 | + LIMITS, | |
| 10 | + SCHEMA_VERSION, | |
| 11 | + isCatalogErrorResult, | |
| 12 | + isPlaceSearchResult, | |
| 13 | + isServiceOptionsResult, | |
| 14 | + type TransportMode, | |
| 15 | +} from "../../contracts/src/index.ts"; | |
| 16 | +import { CatalogManager, handleCatalogRequest } from "../src/catalog.ts"; | |
| 17 | + | |
| 18 | +interface FixturePlace { | |
| 19 | + readonly id: string; | |
| 20 | + readonly label: string; | |
| 21 | + readonly locality: string; | |
| 22 | + readonly mode: TransportMode; | |
| 23 | + readonly serviceId: string; | |
| 24 | + readonly line: string; | |
| 25 | + readonly destination: string; | |
| 26 | + readonly monitoringRef: string; | |
| 27 | + readonly lineRef: string; | |
| 28 | +} | |
| 29 | + | |
| 30 | +const PLACE_ID = `plc_${"a".repeat(43)}`; | |
| 31 | +const SERVICE_ID = `svc_${"f".repeat(43)}`; | |
| 32 | +const PERSONAL_KEY = "test-only-personal-credential"; | |
| 33 | +const temporaryDirectories: string[] = []; | |
| 34 | + | |
| 35 | +const FIXTURE: readonly FixturePlace[] = [ | |
| 36 | + { id: PLACE_ID, label: "Châtelet", locality: "Paris", mode: "METRO", serviceId: SERVICE_ID, line: "4", destination: "Bagneux – Lucie Aubrac", monitoringRef: "raw-monitoring-metro-chatelet", lineRef: "raw-line-metro-4" }, | |
| 37 | + { id: `plc_${"b".repeat(43)}`, label: "République", locality: "Paris", mode: "BUS", serviceId: `svc_${"g".repeat(43)}`, line: "96", destination: "Porte des Lilas", monitoringRef: "raw-monitoring-bus-republique", lineRef: "raw-line-bus-96" }, | |
| 38 | + { id: `plc_${"c".repeat(43)}`, label: "Porte de Versailles", locality: "Paris", mode: "TRAM", serviceId: `svc_${"h".repeat(43)}`, line: "T2", destination: "Pont de Bezons", monitoringRef: "raw-monitoring-tram-versailles", lineRef: "raw-line-tram-t2" }, | |
| 39 | + { id: `plc_${"d".repeat(43)}`, label: "Châtelet – Les Halles", locality: "Paris", mode: "RER", serviceId: `svc_${"i".repeat(43)}`, line: "B", destination: "Aéroport Charles de Gaulle 2", monitoringRef: "raw-monitoring-rer-chatelet", lineRef: "raw-line-rer-b" }, | |
| 40 | + { id: `plc_${"e".repeat(43)}`, label: "Saint-Lazare", locality: "Paris", mode: "TRANSILIEN", serviceId: `svc_${"j".repeat(43)}`, line: "L", destination: "Versailles Rive Droite", monitoringRef: "raw-monitoring-train-saint-lazare", lineRef: "raw-line-train-l" }, | |
| 41 | +]; | |
| 42 | + | |
| 43 | +function normalizedSearchText(place: FixturePlace): string { | |
| 44 | + return `${place.label} ${place.locality} ${place.mode}`.normalize("NFKD") | |
| 45 | + .replace(/\p{M}+/gu, "").toLowerCase().replace(/[^\p{L}\p{N}]+/gu, " ") | |
| 46 | + .trim().replace(/\s+/gu, " "); | |
| 47 | +} | |
| 48 | + | |
| 49 | +function fixtureManager(): CatalogManager { | |
| 50 | + const directory = mkdtempSync(join(tmpdir(), "lapin-catalog-http-")); | |
| 51 | + temporaryDirectories.push(directory); | |
| 52 | + const path = join(directory, "catalog.sqlite"); | |
| 53 | + const database = new DatabaseSync(path); | |
| 54 | + database.exec(` | |
| 55 | + PRAGMA foreign_keys = ON; | |
| 56 | + CREATE TABLE metadata(key TEXT PRIMARY KEY, value TEXT NOT NULL); | |
| 57 | + CREATE TABLE places(place_id TEXT PRIMARY KEY, stop_label TEXT NOT NULL, locality_label TEXT, mode TEXT NOT NULL, canonical_tuple TEXT NOT NULL UNIQUE); | |
| 58 | + CREATE TABLE services(service_id TEXT PRIMARY KEY, place_id TEXT NOT NULL REFERENCES places(place_id), stop_label TEXT NOT NULL, line_label TEXT NOT NULL, destination_label TEXT NOT NULL, monitoring_ref TEXT NOT NULL, line_ref TEXT NOT NULL, direction_id TEXT NOT NULL, destination_ref TEXT NOT NULL, canonical_tuple TEXT NOT NULL UNIQUE); | |
| 59 | + CREATE VIRTUAL TABLE place_search USING fts5(search_text, place_id UNINDEXED); | |
| 60 | + `); | |
| 61 | + const metadata = database.prepare("INSERT INTO metadata(key, value) VALUES (?, ?)"); | |
| 62 | + metadata.run("schema_version", "1"); metadata.run("catalog_version", "1"); | |
| 63 | + metadata.run("source_revision", "http-fixture-revision"); | |
| 64 | + metadata.run("created_at", "2026-08-30T12:00:00.000Z"); | |
| 65 | + const place = database.prepare("INSERT INTO places VALUES (?, ?, ?, ?, ?)"); | |
| 66 | + const search = database.prepare("INSERT INTO place_search(search_text, place_id) VALUES (?, ?)"); | |
| 67 | + const service = database.prepare("INSERT INTO services VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"); | |
| 68 | + FIXTURE.forEach((entry, index) => { | |
| 69 | + place.run(entry.id, entry.label, entry.locality, entry.mode, `place:v1|${index}:${entry.id}`); | |
| 70 | + search.run(normalizedSearchText(entry), entry.id); | |
| 71 | + service.run(entry.serviceId, entry.id, entry.label, entry.line, entry.destination, entry.monitoringRef, entry.lineRef, `direction-${index}`, `raw-terminal-${index}`, `service:v1|${index}:${entry.serviceId}`); | |
| 72 | + }); | |
| 73 | + database.close(); | |
| 74 | + const manager = new CatalogManager(); | |
| 75 | + manager.reload(path); | |
| 76 | + return manager; | |
| 77 | +} | |
| 78 | + | |
| 79 | +function request(catalog: CatalogManager, method: string, url: string) { | |
| 80 | + return handleCatalogRequest({ method, url }, { catalog }); | |
| 81 | +} | |
| 82 | + | |
| 83 | +test.afterEach(() => { | |
| 84 | + while (temporaryDirectories.length > 0) { | |
| 85 | + const directory = temporaryDirectories.pop(); | |
| 86 | + if (directory !== undefined) rmSync(directory, { recursive: true, force: true }); | |
| 87 | + } | |
| 88 | +}); | |
| 89 | + | |
| 90 | +test("GET routes return exact public response bodies without source identifiers or credentials", () => { | |
| 91 | + const catalog = fixtureManager(); | |
| 92 | + const places = request(catalog, "GET", `${CATALOG_ROUTE.places}?q=chatelet`); | |
| 93 | + assert.equal(places.status, 200); | |
| 94 | + assert.ok(isPlaceSearchResult(places.body)); | |
| 95 | + assert.deepEqual(places.body, { schemaVersion: SCHEMA_VERSION, places: [ | |
| 96 | + { placeId: PLACE_ID, stopLabel: "Châtelet", localityLabel: "Paris", mode: "METRO" }, | |
| 97 | + { placeId: FIXTURE[3]!.id, stopLabel: "Châtelet – Les Halles", localityLabel: "Paris", mode: "RER" }, | |
| 98 | + ] }); | |
| 99 | + const services = request(catalog, "GET", CATALOG_ROUTE.placeServices.replace(":placeId", PLACE_ID)); | |
| 100 | + assert.equal(services.status, 200); | |
| 101 | + assert.ok(isServiceOptionsResult(services.body)); | |
| 102 | + assert.deepEqual(services.body, { schemaVersion: SCHEMA_VERSION, placeId: PLACE_ID, services: [ | |
| 103 | + { serviceId: SERVICE_ID, stopLabel: "Châtelet", lineLabel: "4", destinationLabel: "Bagneux – Lucie Aubrac" }, | |
| 104 | + ] }); | |
| 105 | + const publicJson = JSON.stringify([places.body, services.body]); | |
| 106 | + for (const forbidden of [PERSONAL_KEY, "raw-monitoring-metro-chatelet", "raw-line-metro-4", "raw-terminal-0", "direction-0"]) { | |
| 107 | + assert.equal(publicJson.includes(forbidden), false); | |
| 108 | + } | |
| 109 | +}); | |
| 110 | + | |
| 111 | +test("search rejects malformed parameters and accepts exact code-point boundaries", () => { | |
| 112 | + const catalog = fixtureManager(); | |
| 113 | + for (const url of [CATALOG_ROUTE.places, `${CATALOG_ROUTE.places}?q=`, `${CATALOG_ROUTE.places}?q=a`, `${CATALOG_ROUTE.places}?q=${"a".repeat(101)}`, `${CATALOG_ROUTE.places}?q=--`, `${CATALOG_ROUTE.places}?q=ch&q=metro`, `${CATALOG_ROUTE.places}?q=ch&mode=METRO`]) { | |
| 114 | + const result = request(catalog, "GET", url); | |
| 115 | + assert.equal(result.status, 400); | |
| 116 | + assert.deepEqual(result.body, { schemaVersion: SCHEMA_VERSION, code: "INVALID_QUERY" }); | |
| 117 | + assert.ok(isCatalogErrorResult(result.body)); | |
| 118 | + } | |
| 119 | + assert.equal(request(catalog, "GET", `${CATALOG_ROUTE.places}?q=ch`).status, 200); | |
| 120 | + assert.equal(request(catalog, "GET", `${CATALOG_ROUTE.places}?q=${"z".repeat(100)}`).status, 200); | |
| 121 | + const route = CATALOG_ROUTE.placeServices.replace(":placeId", PLACE_ID); | |
| 122 | + assert.deepEqual(request(catalog, "GET", `${route}?raw=forbidden`), { | |
| 123 | + status: 400, body: { schemaVersion: SCHEMA_VERSION, code: "INVALID_QUERY" }, | |
| 124 | + }); | |
| 125 | +}); | |
| 126 | + | |
| 127 | +test("handler distinguishes method, place, route, and unavailable failures", () => { | |
| 128 | + const catalog = fixtureManager(); | |
| 129 | + const route = CATALOG_ROUTE.placeServices.replace(":placeId", PLACE_ID); | |
| 130 | + assert.deepEqual(request(catalog, "POST", CATALOG_ROUTE.places), { status: 405, body: { schemaVersion: SCHEMA_VERSION, code: "METHOD_NOT_ALLOWED" } }); | |
| 131 | + assert.deepEqual(request(catalog, "DELETE", route), { status: 405, body: { schemaVersion: SCHEMA_VERSION, code: "METHOD_NOT_ALLOWED" } }); | |
| 132 | + assert.deepEqual(request(catalog, "GET", CATALOG_ROUTE.placeServices.replace(":placeId", "plc_unknown")), { status: 404, body: { schemaVersion: SCHEMA_VERSION, code: "PLACE_NOT_FOUND" } }); | |
| 133 | + assert.deepEqual(request(catalog, "GET", "/api/catalog/unknown"), { status: 404, body: { schemaVersion: SCHEMA_VERSION, code: "PLACE_NOT_FOUND" } }); | |
| 134 | + const unavailable = new CatalogManager(); | |
| 135 | + assert.deepEqual(request(unavailable, "GET", `${CATALOG_ROUTE.places}?q=ch`), { status: 503, body: { schemaVersion: SCHEMA_VERSION, code: "CATALOG_UNAVAILABLE" } }); | |
| 136 | + assert.deepEqual(request(unavailable, "GET", route), { status: 503, body: { schemaVersion: SCHEMA_VERSION, code: "CATALOG_UNAVAILABLE" } }); | |
| 137 | +}); | |
| 138 | + | |
| 139 | +test("catalog browsing never invokes fetch or a PRIM source", { concurrency: false }, () => { | |
| 140 | + const catalog = fixtureManager(); | |
| 141 | + const originalFetch = globalThis.fetch; | |
| 142 | + let fetchCalls = 0; | |
| 143 | + globalThis.fetch = (() => { fetchCalls += 1; throw new Error("network request"); }) as typeof fetch; | |
| 144 | + try { | |
| 145 | + assert.equal(request(catalog, "GET", `${CATALOG_ROUTE.places}?q=chatelet`).status, 200); | |
| 146 | + assert.equal(request(catalog, "GET", CATALOG_ROUTE.placeServices.replace(":placeId", PLACE_ID)).status, 200); | |
| 147 | + assert.equal(fetchCalls, 0); | |
| 148 | + } finally { | |
| 149 | + globalThis.fetch = originalFetch; | |
| 150 | + } | |
| 151 | +}); | |
| 152 | + | |
| 153 | +test("success collections remain within the shared result bound", () => { | |
| 154 | + const result = request(fixtureManager(), "GET", `${CATALOG_ROUTE.places}?q=ch`); | |
| 155 | + assert.equal(result.status, 200); | |
| 156 | + if (!isPlaceSearchResult(result.body)) assert.fail("expected place search result"); | |
| 157 | + assert.ok(result.body.places.length <= LIMITS.catalogSearchResults); | |
| 158 | +}); | |
| new file mode 100644 | |||
| @@ -0,0 +1,158 @@ | |||
| 1 | +import test from "node:test"; | ||
| 2 | +import assert from "node:assert/strict"; | ||
| 3 | +import { mkdtempSync, rmSync } from "node:fs"; | ||
| 4 | +import { tmpdir } from "node:os"; | ||
| 5 | +import { join } from "node:path"; | ||
| 6 | +import { DatabaseSync } from "node:sqlite"; | ||
| 7 | +import { | ||
| 8 | + CATALOG_ROUTE, | ||
| 9 | + LIMITS, | ||
| 10 | + SCHEMA_VERSION, | ||
| 11 | + isCatalogErrorResult, | ||
| 12 | + isPlaceSearchResult, | ||
| 13 | + isServiceOptionsResult, | ||
| 14 | + type TransportMode, | ||
| 15 | +} from "../../contracts/src/index.ts"; | ||
| 16 | +import { CatalogManager, handleCatalogRequest } from "../src/catalog.ts"; | ||
| 17 | + | ||
| 18 | +interface FixturePlace { | ||
| 19 | + readonly id: string; | ||
| 20 | + readonly label: string; | ||
| 21 | + readonly locality: string; | ||
| 22 | + readonly mode: TransportMode; | ||
| 23 | + readonly serviceId: string; | ||
| 24 | + readonly line: string; | ||
| 25 | + readonly destination: string; | ||
| 26 | + readonly monitoringRef: string; | ||
| 27 | + readonly lineRef: string; | ||
| 28 | +} | ||
| 29 | + | ||
| 30 | +const PLACE_ID = `plc_${"a".repeat(43)}`; | ||
| 31 | +const SERVICE_ID = `svc_${"f".repeat(43)}`; | ||
| 32 | +const PERSONAL_KEY = "test-only-personal-credential"; | ||
| 33 | +const temporaryDirectories: string[] = []; | ||
| 34 | + | ||
| 35 | +const FIXTURE: readonly FixturePlace[] = [ | ||
| 36 | + { id: PLACE_ID, label: "Châtelet", locality: "Paris", mode: "METRO", serviceId: SERVICE_ID, line: "4", destination: "Bagneux – Lucie Aubrac", monitoringRef: "raw-monitoring-metro-chatelet", lineRef: "raw-line-metro-4" }, | ||
| 37 | + { id: `plc_${"b".repeat(43)}`, label: "République", locality: "Paris", mode: "BUS", serviceId: `svc_${"g".repeat(43)}`, line: "96", destination: "Porte des Lilas", monitoringRef: "raw-monitoring-bus-republique", lineRef: "raw-line-bus-96" }, | ||
| 38 | + { id: `plc_${"c".repeat(43)}`, label: "Porte de Versailles", locality: "Paris", mode: "TRAM", serviceId: `svc_${"h".repeat(43)}`, line: "T2", destination: "Pont de Bezons", monitoringRef: "raw-monitoring-tram-versailles", lineRef: "raw-line-tram-t2" }, | ||
| 39 | + { id: `plc_${"d".repeat(43)}`, label: "Châtelet – Les Halles", locality: "Paris", mode: "RER", serviceId: `svc_${"i".repeat(43)}`, line: "B", destination: "Aéroport Charles de Gaulle 2", monitoringRef: "raw-monitoring-rer-chatelet", lineRef: "raw-line-rer-b" }, | ||
| 40 | + { id: `plc_${"e".repeat(43)}`, label: "Saint-Lazare", locality: "Paris", mode: "TRANSILIEN", serviceId: `svc_${"j".repeat(43)}`, line: "L", destination: "Versailles Rive Droite", monitoringRef: "raw-monitoring-train-saint-lazare", lineRef: "raw-line-train-l" }, | ||
| 41 | +]; | ||
| 42 | + | ||
| 43 | +function normalizedSearchText(place: FixturePlace): string { | ||
| 44 | + return `${place.label} ${place.locality} ${place.mode}`.normalize("NFKD") | ||
| 45 | + .replace(/\p{M}+/gu, "").toLowerCase().replace(/[^\p{L}\p{N}]+/gu, " ") | ||
| 46 | + .trim().replace(/\s+/gu, " "); | ||
| 47 | +} | ||
| 48 | + | ||
| 49 | +function fixtureManager(): CatalogManager { | ||
| 50 | + const directory = mkdtempSync(join(tmpdir(), "lapin-catalog-http-")); | ||
| 51 | + temporaryDirectories.push(directory); | ||
| 52 | + const path = join(directory, "catalog.sqlite"); | ||
| 53 | + const database = new DatabaseSync(path); | ||
| 54 | + database.exec(` | ||
| 55 | + PRAGMA foreign_keys = ON; | ||
| 56 | + CREATE TABLE metadata(key TEXT PRIMARY KEY, value TEXT NOT NULL); | ||
| 57 | + CREATE TABLE places(place_id TEXT PRIMARY KEY, stop_label TEXT NOT NULL, locality_label TEXT, mode TEXT NOT NULL, canonical_tuple TEXT NOT NULL UNIQUE); | ||
| 58 | + CREATE TABLE services(service_id TEXT PRIMARY KEY, place_id TEXT NOT NULL REFERENCES places(place_id), stop_label TEXT NOT NULL, line_label TEXT NOT NULL, destination_label TEXT NOT NULL, monitoring_ref TEXT NOT NULL, line_ref TEXT NOT NULL, direction_id TEXT NOT NULL, destination_ref TEXT NOT NULL, canonical_tuple TEXT NOT NULL UNIQUE); | ||
| 59 | + CREATE VIRTUAL TABLE place_search USING fts5(search_text, place_id UNINDEXED); | ||
| 60 | + `); | ||
| 61 | + const metadata = database.prepare("INSERT INTO metadata(key, value) VALUES (?, ?)"); | ||
| 62 | + metadata.run("schema_version", "1"); metadata.run("catalog_version", "1"); | ||
| 63 | + metadata.run("source_revision", "http-fixture-revision"); | ||
| 64 | + metadata.run("created_at", "2026-08-30T12:00:00.000Z"); | ||
| 65 | + const place = database.prepare("INSERT INTO places VALUES (?, ?, ?, ?, ?)"); | ||
| 66 | + const search = database.prepare("INSERT INTO place_search(search_text, place_id) VALUES (?, ?)"); | ||
| 67 | + const service = database.prepare("INSERT INTO services VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"); | ||
| 68 | + FIXTURE.forEach((entry, index) => { | ||
| 69 | + place.run(entry.id, entry.label, entry.locality, entry.mode, `place:v1|${index}:${entry.id}`); | ||
| 70 | + search.run(normalizedSearchText(entry), entry.id); | ||
| 71 | + service.run(entry.serviceId, entry.id, entry.label, entry.line, entry.destination, entry.monitoringRef, entry.lineRef, `direction-${index}`, `raw-terminal-${index}`, `service:v1|${index}:${entry.serviceId}`); | ||
| 72 | + }); | ||
| 73 | + database.close(); | ||
| 74 | + const manager = new CatalogManager(); | ||
| 75 | + manager.reload(path); | ||
| 76 | + return manager; | ||
| 77 | +} | ||
| 78 | + | ||
| 79 | +function request(catalog: CatalogManager, method: string, url: string) { | ||
| 80 | + return handleCatalogRequest({ method, url }, { catalog }); | ||
| 81 | +} | ||
| 82 | + | ||
| 83 | +test.afterEach(() => { | ||
| 84 | + while (temporaryDirectories.length > 0) { | ||
| 85 | + const directory = temporaryDirectories.pop(); | ||
| 86 | + if (directory !== undefined) rmSync(directory, { recursive: true, force: true }); | ||
| 87 | + } | ||
| 88 | +}); | ||
| 89 | + | ||
| 90 | +test("GET routes return exact public response bodies without source identifiers or credentials", () => { | ||
| 91 | + const catalog = fixtureManager(); | ||
| 92 | + const places = request(catalog, "GET", `${CATALOG_ROUTE.places}?q=chatelet`); | ||
| 93 | + assert.equal(places.status, 200); | ||
| 94 | + assert.ok(isPlaceSearchResult(places.body)); | ||
| 95 | + assert.deepEqual(places.body, { schemaVersion: SCHEMA_VERSION, places: [ | ||
| 96 | + { placeId: PLACE_ID, stopLabel: "Châtelet", localityLabel: "Paris", mode: "METRO" }, | ||
| 97 | + { placeId: FIXTURE[3]!.id, stopLabel: "Châtelet – Les Halles", localityLabel: "Paris", mode: "RER" }, | ||
| 98 | + ] }); | ||
| 99 | + const services = request(catalog, "GET", CATALOG_ROUTE.placeServices.replace(":placeId", PLACE_ID)); | ||
| 100 | + assert.equal(services.status, 200); | ||
| 101 | + assert.ok(isServiceOptionsResult(services.body)); | ||
| 102 | + assert.deepEqual(services.body, { schemaVersion: SCHEMA_VERSION, placeId: PLACE_ID, services: [ | ||
| 103 | + { serviceId: SERVICE_ID, stopLabel: "Châtelet", lineLabel: "4", destinationLabel: "Bagneux – Lucie Aubrac" }, | ||
| 104 | + ] }); | ||
| 105 | + const publicJson = JSON.stringify([places.body, services.body]); | ||
| 106 | + for (const forbidden of [PERSONAL_KEY, "raw-monitoring-metro-chatelet", "raw-line-metro-4", "raw-terminal-0", "direction-0"]) { | ||
| 107 | + assert.equal(publicJson.includes(forbidden), false); | ||
| 108 | + } | ||
| 109 | +}); | ||
| 110 | + | ||
| 111 | +test("search rejects malformed parameters and accepts exact code-point boundaries", () => { | ||
| 112 | + const catalog = fixtureManager(); | ||
| 113 | + for (const url of [CATALOG_ROUTE.places, `${CATALOG_ROUTE.places}?q=`, `${CATALOG_ROUTE.places}?q=a`, `${CATALOG_ROUTE.places}?q=${"a".repeat(101)}`, `${CATALOG_ROUTE.places}?q=--`, `${CATALOG_ROUTE.places}?q=ch&q=metro`, `${CATALOG_ROUTE.places}?q=ch&mode=METRO`]) { | ||
| 114 | + const result = request(catalog, "GET", url); | ||
| 115 | + assert.equal(result.status, 400); | ||
| 116 | + assert.deepEqual(result.body, { schemaVersion: SCHEMA_VERSION, code: "INVALID_QUERY" }); | ||
| 117 | + assert.ok(isCatalogErrorResult(result.body)); | ||
| 118 | + } | ||
| 119 | + assert.equal(request(catalog, "GET", `${CATALOG_ROUTE.places}?q=ch`).status, 200); | ||
| 120 | + assert.equal(request(catalog, "GET", `${CATALOG_ROUTE.places}?q=${"z".repeat(100)}`).status, 200); | ||
| 121 | + const route = CATALOG_ROUTE.placeServices.replace(":placeId", PLACE_ID); | ||
| 122 | + assert.deepEqual(request(catalog, "GET", `${route}?raw=forbidden`), { | ||
| 123 | + status: 400, body: { schemaVersion: SCHEMA_VERSION, code: "INVALID_QUERY" }, | ||
| 124 | + }); | ||
| 125 | +}); | ||
| 126 | + | ||
| 127 | +test("handler distinguishes method, place, route, and unavailable failures", () => { | ||
| 128 | + const catalog = fixtureManager(); | ||
| 129 | + const route = CATALOG_ROUTE.placeServices.replace(":placeId", PLACE_ID); | ||
| 130 | + assert.deepEqual(request(catalog, "POST", CATALOG_ROUTE.places), { status: 405, body: { schemaVersion: SCHEMA_VERSION, code: "METHOD_NOT_ALLOWED" } }); | ||
| 131 | + assert.deepEqual(request(catalog, "DELETE", route), { status: 405, body: { schemaVersion: SCHEMA_VERSION, code: "METHOD_NOT_ALLOWED" } }); | ||
| 132 | + assert.deepEqual(request(catalog, "GET", CATALOG_ROUTE.placeServices.replace(":placeId", "plc_unknown")), { status: 404, body: { schemaVersion: SCHEMA_VERSION, code: "PLACE_NOT_FOUND" } }); | ||
| 133 | + assert.deepEqual(request(catalog, "GET", "/api/catalog/unknown"), { status: 404, body: { schemaVersion: SCHEMA_VERSION, code: "PLACE_NOT_FOUND" } }); | ||
| 134 | + const unavailable = new CatalogManager(); | ||
| 135 | + assert.deepEqual(request(unavailable, "GET", `${CATALOG_ROUTE.places}?q=ch`), { status: 503, body: { schemaVersion: SCHEMA_VERSION, code: "CATALOG_UNAVAILABLE" } }); | ||
| 136 | + assert.deepEqual(request(unavailable, "GET", route), { status: 503, body: { schemaVersion: SCHEMA_VERSION, code: "CATALOG_UNAVAILABLE" } }); | ||
| 137 | +}); | ||
| 138 | + | ||
| 139 | +test("catalog browsing never invokes fetch or a PRIM source", { concurrency: false }, () => { | ||
| 140 | + const catalog = fixtureManager(); | ||
| 141 | + const originalFetch = globalThis.fetch; | ||
| 142 | + let fetchCalls = 0; | ||
| 143 | + globalThis.fetch = (() => { fetchCalls += 1; throw new Error("network request"); }) as typeof fetch; | ||
| 144 | + try { | ||
| 145 | + assert.equal(request(catalog, "GET", `${CATALOG_ROUTE.places}?q=chatelet`).status, 200); | ||
| 146 | + assert.equal(request(catalog, "GET", CATALOG_ROUTE.placeServices.replace(":placeId", PLACE_ID)).status, 200); | ||
| 147 | + assert.equal(fetchCalls, 0); | ||
| 148 | + } finally { | ||
| 149 | + globalThis.fetch = originalFetch; | ||
| 150 | + } | ||
| 151 | +}); | ||
| 152 | + | ||
| 153 | +test("success collections remain within the shared result bound", () => { | ||
| 154 | + const result = request(fixtureManager(), "GET", `${CATALOG_ROUTE.places}?q=ch`); | ||
| 155 | + assert.equal(result.status, 200); | ||
| 156 | + if (!isPlaceSearchResult(result.body)) assert.fail("expected place search result"); | ||
| 157 | + assert.ok(result.body.places.length <= LIMITS.catalogSearchResults); | ||
| 158 | +}); | ||
added
packages/backend/test/catalog-import.test.ts +185 -0 | new file mode 100644 | ||
| @@ -0,0 +1,185 @@ | ||
| 1 | +import test from "node:test"; | |
| 2 | +import type { TestContext } from "node:test"; | |
| 3 | +import assert from "node:assert/strict"; | |
| 4 | +import { createHash } from "node:crypto"; | |
| 5 | +import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; | |
| 6 | +import { tmpdir } from "node:os"; | |
| 7 | +import { join } from "node:path"; | |
| 8 | +import { TRANSPORT_MODE } from "../../contracts/src/index.ts"; | |
| 9 | +import type { TransportMode } from "../../contracts/src/index.ts"; | |
| 10 | +import { CatalogManager } from "../src/catalog.ts"; | |
| 11 | +import { | |
| 12 | + activateCatalogCandidate, | |
| 13 | + buildCatalogCandidateFromRecords, | |
| 14 | + createPlaceIdentity, | |
| 15 | + createServiceIdentity, | |
| 16 | + downloadCatalogSource, | |
| 17 | + refreshCatalogFromRecords, | |
| 18 | +} from "../src/catalog-import.ts"; | |
| 19 | +import type { | |
| 20 | + CatalogRecordSourceSet, | |
| 21 | + CsvRecord, | |
| 22 | +} from "../src/catalog-import.ts"; | |
| 23 | + | |
| 24 | +interface Fixture { | |
| 25 | + readonly schemaVersion: number; | |
| 26 | + readonly fixtureVersion: string; | |
| 27 | + readonly dataClassification: string; | |
| 28 | + readonly attribution: readonly { readonly dataset: string; readonly url: string; readonly retrievedAt: string; readonly license: string }[]; | |
| 29 | + readonly sources: Readonly<Record<keyof CatalogRecordSourceSet, readonly Record<string, unknown>[]>>; | |
| 30 | +} | |
| 31 | + | |
| 32 | +const fixtureUrl = new URL("../../../fixtures/catalog/idfm-v1.json", import.meta.url); | |
| 33 | +const fixtureText = readFileSync(fixtureUrl, "utf8"); | |
| 34 | +const fixture = JSON.parse(fixtureText) as Fixture; | |
| 35 | + | |
| 36 | +function sourcesFor(revisionId: string): CatalogRecordSourceSet { | |
| 37 | + return Object.fromEntries(Object.entries(fixture.sources).map(([name, records]) => [ | |
| 38 | + name, | |
| 39 | + records.filter((record) => !Array.isArray(record.revisions) || record.revisions.includes(revisionId)).map((record) => { | |
| 40 | + const { revisions: _revisions, ...sourceRecord } = record; | |
| 41 | + return sourceRecord as CsvRecord; | |
| 42 | + }), | |
| 43 | + ])) as unknown as CatalogRecordSourceSet; | |
| 44 | +} | |
| 45 | + | |
| 46 | +function workspace(t: TestContext): string { | |
| 47 | + const directory = mkdtempSync(join(tmpdir(), "lapin-fute-catalog-import-")); | |
| 48 | + t.after(() => rmSync(directory, { recursive: true, force: true })); | |
| 49 | + return directory; | |
| 50 | +} | |
| 51 | + | |
| 52 | +function place(manager: CatalogManager, query: string, mode: TransportMode, locality?: string) { | |
| 53 | + const found = manager.searchPlaces(query).find((entry) => entry.mode === mode && (locality === undefined || entry.localityLabel === locality)); | |
| 54 | + assert.ok(found, `expected ${mode} place for ${query}`); | |
| 55 | + return found; | |
| 56 | +} | |
| 57 | + | |
| 58 | +function service(manager: CatalogManager, placeId: string, destination: string) { | |
| 59 | + const found = manager.listServices(placeId)?.find((entry) => entry.destinationLabel === destination); | |
| 60 | + assert.ok(found, `expected service to ${destination}`); | |
| 61 | + return found; | |
| 62 | +} | |
| 63 | + | |
| 64 | +test("fixture is attributed and contains neither credentials nor personal responses", () => { | |
| 65 | + assert.equal(fixture.schemaVersion, 1); | |
| 66 | + assert.equal(fixture.fixtureVersion, "idfm-catalog-v1"); | |
| 67 | + assert.match(fixture.dataClassification, /fixture-only/u); | |
| 68 | + assert.equal(fixture.attribution.length, 7); | |
| 69 | + for (const source of fixture.attribution) { | |
| 70 | + assert.equal(new URL(source.url).hostname, "data.iledefrance-mobilites.fr"); | |
| 71 | + assert.ok(Number.isFinite(Date.parse(source.retrievedAt))); | |
| 72 | + assert.ok(source.license.length > 0); | |
| 73 | + } | |
| 74 | + assert.doesNotMatch(fixtureText, /IDFM_DATASET_TOKEN|PRIM_API_KEY|"apikey"|StopMonitoringDelivery|MonitoredStopVisit/iu); | |
| 75 | +}); | |
| 76 | + | |
| 77 | +test("opaque IDs use stable full SHA-256 identities and distinguish opposite directions", () => { | |
| 78 | + const firstPlace = createPlaceIdentity("METRO", "ZDA-METRO"); | |
| 79 | + assert.deepEqual(firstPlace, createPlaceIdentity("METRO", "ZDA-METRO")); | |
| 80 | + assert.match(firstPlace.placeId, /^plc_[A-Za-z0-9_-]{43}$/u); | |
| 81 | + const east = createServiceIdentity({ mode: "METRO", lineId: "C200", monitoringRef: "STIF:StopPoint:Q:METRO-EAST:", directionId: "0", destinationRef: "DEST:METRO-SOUTH" }); | |
| 82 | + const west = createServiceIdentity({ mode: "METRO", lineId: "C200", monitoringRef: "STIF:StopPoint:Q:METRO-WEST:", directionId: "1", destinationRef: "DEST:METRO-NORTH" }); | |
| 83 | + assert.match(east.serviceId, /^svc_[A-Za-z0-9_-]{43}$/u); | |
| 84 | + assert.notEqual(east.serviceId, west.serviceId); | |
| 85 | + assert.match(east.canonicalTuple, /^lapin-fute:service:v1\|/u); | |
| 86 | +}); | |
| 87 | + | |
| 88 | +test("import covers five modes, gates perimeter and unsupported modes, preserves homonyms, and uses rail terminals", async (t) => { | |
| 89 | + const directory = workspace(t); | |
| 90 | + const candidate = join(directory, "candidate.sqlite"); | |
| 91 | + const active = join(directory, "catalog.sqlite"); | |
| 92 | + const result = await buildCatalogCandidateFromRecords({ candidatePath: candidate, sourceRevision: "fixture-2026-08-a", createdAt: "2026-08-30T00:00:00.000Z", sources: sourcesFor("fixture-2026-08-a") }); | |
| 93 | + assert.equal(result.placeCount, 6); | |
| 94 | + assert.equal(result.serviceCount, 8); | |
| 95 | + assert.deepEqual(result.countsByMode, { BUS: 2, METRO: 2, TRAM: 1, RER: 2, TRANSILIEN: 1 }); | |
| 96 | + for (const mode of TRANSPORT_MODE) assert.ok(result.countsByMode[mode] > 0); | |
| 97 | + | |
| 98 | + activateCatalogCandidate(candidate, active); | |
| 99 | + const manager = new CatalogManager(); | |
| 100 | + manager.reload(active); | |
| 101 | + const homonyms = manager.searchPlaces("gare du nord").filter((entry) => entry.mode === "BUS"); | |
| 102 | + assert.equal(homonyms.length, 2); | |
| 103 | + assert.notEqual(homonyms[0]?.placeId, homonyms[1]?.placeId); | |
| 104 | + assert.deepEqual(new Set(homonyms.map((entry) => entry.localityLabel)), new Set(["Paris", "Saint-Denis"])); | |
| 105 | + | |
| 106 | + const metro = place(manager, "chatelet", "METRO"); | |
| 107 | + const metroServices = manager.listServices(metro.placeId) ?? []; | |
| 108 | + assert.equal(metroServices.length, 2); | |
| 109 | + assert.notEqual(metroServices[0]?.serviceId, metroServices[1]?.serviceId); | |
| 110 | + const rer = place(manager, "chatelet les halles", "RER"); | |
| 111 | + const east = service(manager, rer.placeId, "Marne-la-Vallée Chessy"); | |
| 112 | + const west = service(manager, rer.placeId, "Saint-Germain-en-Laye"); | |
| 113 | + const eastResolution = manager.resolveService(east.serviceId); | |
| 114 | + const westResolution = manager.resolveService(west.serviceId); | |
| 115 | + assert.equal(eastResolution.status, "RESOLVED"); | |
| 116 | + assert.equal(westResolution.status, "RESOLVED"); | |
| 117 | + if (eastResolution.status === "RESOLVED" && westResolution.status === "RESOLVED") { | |
| 118 | + assert.equal(eastResolution.monitoringRef, westResolution.monitoringRef); | |
| 119 | + assert.notEqual(eastResolution.directionId, westResolution.directionId); | |
| 120 | + assert.notEqual(eastResolution.destinationRef, westResolution.destinationRef); | |
| 121 | + assert.notEqual(eastResolution.destinationLabel, "NORA"); | |
| 122 | + assert.notEqual(westResolution.destinationLabel, "ZEBU"); | |
| 123 | + } | |
| 124 | + assert.equal(manager.searchPlaces("montmartre").length, 0); | |
| 125 | + const bus = place(manager, "gare du nord", "BUS", "Paris"); | |
| 126 | + const busServices = manager.listServices(bus.placeId) ?? []; | |
| 127 | + assert.deepEqual(busServices.map((entry) => entry.destinationLabel), ["Hôpital Européen"]); | |
| 128 | + assert.equal(busServices.some((entry) => entry.destinationLabel === "Wrong perimeter destination"), false); | |
| 129 | + assert.doesNotMatch(JSON.stringify([...manager.searchPlaces("chatelet"), ...metroServices, ...busServices]), /STIF:|IDFM:|DEST:/u); | |
| 130 | +}); | |
| 131 | + | |
| 132 | +test("activation preserves stable services, removes stale IDs, and failed replacement leaves the active file untouched", async (t) => { | |
| 133 | + const directory = workspace(t); | |
| 134 | + const active = join(directory, "catalog.sqlite"); | |
| 135 | + const first = join(directory, "first.sqlite"); | |
| 136 | + await buildCatalogCandidateFromRecords({ candidatePath: first, sourceRevision: "fixture-2026-08-a", sources: sourcesFor("fixture-2026-08-a") }); | |
| 137 | + activateCatalogCandidate(first, active); | |
| 138 | + const manager = new CatalogManager(); | |
| 139 | + manager.reload(active); | |
| 140 | + const busId = service(manager, place(manager, "gare du nord", "BUS", "Paris").placeId, "Hôpital Européen").serviceId; | |
| 141 | + const removedId = service(manager, place(manager, "chatelet", "METRO").placeId, "Porte de Clignancourt").serviceId; | |
| 142 | + | |
| 143 | + const second = join(directory, "second.sqlite"); | |
| 144 | + await buildCatalogCandidateFromRecords({ candidatePath: second, sourceRevision: "fixture-2026-08-b", sources: sourcesFor("fixture-2026-08-b") }); | |
| 145 | + activateCatalogCandidate(second, active); | |
| 146 | + assert.equal(manager.resolveService(removedId).status, "RESOLVED"); | |
| 147 | + manager.reload(active); | |
| 148 | + assert.equal(service(manager, place(manager, "gare du nord", "BUS", "Paris").placeId, "Hôpital Européen").serviceId, busId); | |
| 149 | + assert.deepEqual(manager.resolveService(removedId), { status: "UNRESOLVED", code: "INVALID_SERVICE" }); | |
| 150 | + | |
| 151 | + const before = createHash("sha256").update(readFileSync(active)).digest("hex"); | |
| 152 | + const invalid = join(directory, "invalid.sqlite"); | |
| 153 | + await assert.rejects(refreshCatalogFromRecords({ candidatePath: invalid, activePath: active, sourceRevision: "fixture-invalid-no-transilien", sources: sourcesFor("fixture-invalid-no-transilien") }), /no resolvable TRANSILIEN/u); | |
| 154 | + assert.equal(existsSync(invalid), false); | |
| 155 | + assert.equal(createHash("sha256").update(readFileSync(active)).digest("hex"), before); | |
| 156 | + assert.equal(manager.resolveService(busId).status, "RESOLVED"); | |
| 157 | +}); | |
| 158 | + | |
| 159 | +test("restricted source download streams bytes and puts its token only in Authorization", async (t) => { | |
| 160 | + const directory = workspace(t); | |
| 161 | + const destination = join(directory, "source.csv"); | |
| 162 | + const bytes = new TextEncoder().encode("id;name\n1;Châtelet\n"); | |
| 163 | + let requested = ""; | |
| 164 | + let authorization: string | null = null; | |
| 165 | + let redirect: RequestRedirect | undefined; | |
| 166 | + const fetchSource = (async (input: string | URL | Request, init?: RequestInit) => { | |
| 167 | + requested = String(input); | |
| 168 | + authorization = new Headers(init?.headers).get("Authorization"); | |
| 169 | + redirect = init?.redirect; | |
| 170 | + return new Response(bytes, { status: 200, headers: { "content-type": "text/csv; charset=utf-8" } }); | |
| 171 | + }) as typeof globalThis.fetch; | |
| 172 | + const result = await downloadCatalogSource({ | |
| 173 | + source: { dataset: "restricted-fixture", url: "https://data.iledefrance-mobilites.fr/source.csv", retrievedAt: "2026-08-30T00:00:00.000Z", license: "Licence Mobilité", restricted: true }, | |
| 174 | + destinationPath: destination, | |
| 175 | + expectedContentTypes: ["text/csv"], | |
| 176 | + datasetToken: "operator-test-value", | |
| 177 | + fetch: fetchSource, | |
| 178 | + }); | |
| 179 | + assert.equal(requested.includes("operator-test-value"), false); | |
| 180 | + assert.equal(authorization, "apikey operator-test-value"); | |
| 181 | + assert.equal(redirect, "manual"); | |
| 182 | + assert.equal(result.bytes, bytes.byteLength); | |
| 183 | + assert.equal(result.sha256, createHash("sha256").update(bytes).digest("hex")); | |
| 184 | + assert.deepEqual(readFileSync(destination), Buffer.from(bytes)); | |
| 185 | +}); | |
| new file mode 100644 | |||
| @@ -0,0 +1,185 @@ | |||
| 1 | +import test from "node:test"; | ||
| 2 | +import type { TestContext } from "node:test"; | ||
| 3 | +import assert from "node:assert/strict"; | ||
| 4 | +import { createHash } from "node:crypto"; | ||
| 5 | +import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; | ||
| 6 | +import { tmpdir } from "node:os"; | ||
| 7 | +import { join } from "node:path"; | ||
| 8 | +import { TRANSPORT_MODE } from "../../contracts/src/index.ts"; | ||
| 9 | +import type { TransportMode } from "../../contracts/src/index.ts"; | ||
| 10 | +import { CatalogManager } from "../src/catalog.ts"; | ||
| 11 | +import { | ||
| 12 | + activateCatalogCandidate, | ||
| 13 | + buildCatalogCandidateFromRecords, | ||
| 14 | + createPlaceIdentity, | ||
| 15 | + createServiceIdentity, | ||
| 16 | + downloadCatalogSource, | ||
| 17 | + refreshCatalogFromRecords, | ||
| 18 | +} from "../src/catalog-import.ts"; | ||
| 19 | +import type { | ||
| 20 | + CatalogRecordSourceSet, | ||
| 21 | + CsvRecord, | ||
| 22 | +} from "../src/catalog-import.ts"; | ||
| 23 | + | ||
| 24 | +interface Fixture { | ||
| 25 | + readonly schemaVersion: number; | ||
| 26 | + readonly fixtureVersion: string; | ||
| 27 | + readonly dataClassification: string; | ||
| 28 | + readonly attribution: readonly { readonly dataset: string; readonly url: string; readonly retrievedAt: string; readonly license: string }[]; | ||
| 29 | + readonly sources: Readonly<Record<keyof CatalogRecordSourceSet, readonly Record<string, unknown>[]>>; | ||
| 30 | +} | ||
| 31 | + | ||
| 32 | +const fixtureUrl = new URL("../../../fixtures/catalog/idfm-v1.json", import.meta.url); | ||
| 33 | +const fixtureText = readFileSync(fixtureUrl, "utf8"); | ||
| 34 | +const fixture = JSON.parse(fixtureText) as Fixture; | ||
| 35 | + | ||
| 36 | +function sourcesFor(revisionId: string): CatalogRecordSourceSet { | ||
| 37 | + return Object.fromEntries(Object.entries(fixture.sources).map(([name, records]) => [ | ||
| 38 | + name, | ||
| 39 | + records.filter((record) => !Array.isArray(record.revisions) || record.revisions.includes(revisionId)).map((record) => { | ||
| 40 | + const { revisions: _revisions, ...sourceRecord } = record; | ||
| 41 | + return sourceRecord as CsvRecord; | ||
| 42 | + }), | ||
| 43 | + ])) as unknown as CatalogRecordSourceSet; | ||
| 44 | +} | ||
| 45 | + | ||
| 46 | +function workspace(t: TestContext): string { | ||
| 47 | + const directory = mkdtempSync(join(tmpdir(), "lapin-fute-catalog-import-")); | ||
| 48 | + t.after(() => rmSync(directory, { recursive: true, force: true })); | ||
| 49 | + return directory; | ||
| 50 | +} | ||
| 51 | + | ||
| 52 | +function place(manager: CatalogManager, query: string, mode: TransportMode, locality?: string) { | ||
| 53 | + const found = manager.searchPlaces(query).find((entry) => entry.mode === mode && (locality === undefined || entry.localityLabel === locality)); | ||
| 54 | + assert.ok(found, `expected ${mode} place for ${query}`); | ||
| 55 | + return found; | ||
| 56 | +} | ||
| 57 | + | ||
| 58 | +function service(manager: CatalogManager, placeId: string, destination: string) { | ||
| 59 | + const found = manager.listServices(placeId)?.find((entry) => entry.destinationLabel === destination); | ||
| 60 | + assert.ok(found, `expected service to ${destination}`); | ||
| 61 | + return found; | ||
| 62 | +} | ||
| 63 | + | ||
| 64 | +test("fixture is attributed and contains neither credentials nor personal responses", () => { | ||
| 65 | + assert.equal(fixture.schemaVersion, 1); | ||
| 66 | + assert.equal(fixture.fixtureVersion, "idfm-catalog-v1"); | ||
| 67 | + assert.match(fixture.dataClassification, /fixture-only/u); | ||
| 68 | + assert.equal(fixture.attribution.length, 7); | ||
| 69 | + for (const source of fixture.attribution) { | ||
| 70 | + assert.equal(new URL(source.url).hostname, "data.iledefrance-mobilites.fr"); | ||
| 71 | + assert.ok(Number.isFinite(Date.parse(source.retrievedAt))); | ||
| 72 | + assert.ok(source.license.length > 0); | ||
| 73 | + } | ||
| 74 | + assert.doesNotMatch(fixtureText, /IDFM_DATASET_TOKEN|PRIM_API_KEY|"apikey"|StopMonitoringDelivery|MonitoredStopVisit/iu); | ||
| 75 | +}); | ||
| 76 | + | ||
| 77 | +test("opaque IDs use stable full SHA-256 identities and distinguish opposite directions", () => { | ||
| 78 | + const firstPlace = createPlaceIdentity("METRO", "ZDA-METRO"); | ||
| 79 | + assert.deepEqual(firstPlace, createPlaceIdentity("METRO", "ZDA-METRO")); | ||
| 80 | + assert.match(firstPlace.placeId, /^plc_[A-Za-z0-9_-]{43}$/u); | ||
| 81 | + const east = createServiceIdentity({ mode: "METRO", lineId: "C200", monitoringRef: "STIF:StopPoint:Q:METRO-EAST:", directionId: "0", destinationRef: "DEST:METRO-SOUTH" }); | ||
| 82 | + const west = createServiceIdentity({ mode: "METRO", lineId: "C200", monitoringRef: "STIF:StopPoint:Q:METRO-WEST:", directionId: "1", destinationRef: "DEST:METRO-NORTH" }); | ||
| 83 | + assert.match(east.serviceId, /^svc_[A-Za-z0-9_-]{43}$/u); | ||
| 84 | + assert.notEqual(east.serviceId, west.serviceId); | ||
| 85 | + assert.match(east.canonicalTuple, /^lapin-fute:service:v1\|/u); | ||
| 86 | +}); | ||
| 87 | + | ||
| 88 | +test("import covers five modes, gates perimeter and unsupported modes, preserves homonyms, and uses rail terminals", async (t) => { | ||
| 89 | + const directory = workspace(t); | ||
| 90 | + const candidate = join(directory, "candidate.sqlite"); | ||
| 91 | + const active = join(directory, "catalog.sqlite"); | ||
| 92 | + const result = await buildCatalogCandidateFromRecords({ candidatePath: candidate, sourceRevision: "fixture-2026-08-a", createdAt: "2026-08-30T00:00:00.000Z", sources: sourcesFor("fixture-2026-08-a") }); | ||
| 93 | + assert.equal(result.placeCount, 6); | ||
| 94 | + assert.equal(result.serviceCount, 8); | ||
| 95 | + assert.deepEqual(result.countsByMode, { BUS: 2, METRO: 2, TRAM: 1, RER: 2, TRANSILIEN: 1 }); | ||
| 96 | + for (const mode of TRANSPORT_MODE) assert.ok(result.countsByMode[mode] > 0); | ||
| 97 | + | ||
| 98 | + activateCatalogCandidate(candidate, active); | ||
| 99 | + const manager = new CatalogManager(); | ||
| 100 | + manager.reload(active); | ||
| 101 | + const homonyms = manager.searchPlaces("gare du nord").filter((entry) => entry.mode === "BUS"); | ||
| 102 | + assert.equal(homonyms.length, 2); | ||
| 103 | + assert.notEqual(homonyms[0]?.placeId, homonyms[1]?.placeId); | ||
| 104 | + assert.deepEqual(new Set(homonyms.map((entry) => entry.localityLabel)), new Set(["Paris", "Saint-Denis"])); | ||
| 105 | + | ||
| 106 | + const metro = place(manager, "chatelet", "METRO"); | ||
| 107 | + const metroServices = manager.listServices(metro.placeId) ?? []; | ||
| 108 | + assert.equal(metroServices.length, 2); | ||
| 109 | + assert.notEqual(metroServices[0]?.serviceId, metroServices[1]?.serviceId); | ||
| 110 | + const rer = place(manager, "chatelet les halles", "RER"); | ||
| 111 | + const east = service(manager, rer.placeId, "Marne-la-Vallée Chessy"); | ||
| 112 | + const west = service(manager, rer.placeId, "Saint-Germain-en-Laye"); | ||
| 113 | + const eastResolution = manager.resolveService(east.serviceId); | ||
| 114 | + const westResolution = manager.resolveService(west.serviceId); | ||
| 115 | + assert.equal(eastResolution.status, "RESOLVED"); | ||
| 116 | + assert.equal(westResolution.status, "RESOLVED"); | ||
| 117 | + if (eastResolution.status === "RESOLVED" && westResolution.status === "RESOLVED") { | ||
| 118 | + assert.equal(eastResolution.monitoringRef, westResolution.monitoringRef); | ||
| 119 | + assert.notEqual(eastResolution.directionId, westResolution.directionId); | ||
| 120 | + assert.notEqual(eastResolution.destinationRef, westResolution.destinationRef); | ||
| 121 | + assert.notEqual(eastResolution.destinationLabel, "NORA"); | ||
| 122 | + assert.notEqual(westResolution.destinationLabel, "ZEBU"); | ||
| 123 | + } | ||
| 124 | + assert.equal(manager.searchPlaces("montmartre").length, 0); | ||
| 125 | + const bus = place(manager, "gare du nord", "BUS", "Paris"); | ||
| 126 | + const busServices = manager.listServices(bus.placeId) ?? []; | ||
| 127 | + assert.deepEqual(busServices.map((entry) => entry.destinationLabel), ["Hôpital Européen"]); | ||
| 128 | + assert.equal(busServices.some((entry) => entry.destinationLabel === "Wrong perimeter destination"), false); | ||
| 129 | + assert.doesNotMatch(JSON.stringify([...manager.searchPlaces("chatelet"), ...metroServices, ...busServices]), /STIF:|IDFM:|DEST:/u); | ||
| 130 | +}); | ||
| 131 | + | ||
| 132 | +test("activation preserves stable services, removes stale IDs, and failed replacement leaves the active file untouched", async (t) => { | ||
| 133 | + const directory = workspace(t); | ||
| 134 | + const active = join(directory, "catalog.sqlite"); | ||
| 135 | + const first = join(directory, "first.sqlite"); | ||
| 136 | + await buildCatalogCandidateFromRecords({ candidatePath: first, sourceRevision: "fixture-2026-08-a", sources: sourcesFor("fixture-2026-08-a") }); | ||
| 137 | + activateCatalogCandidate(first, active); | ||
| 138 | + const manager = new CatalogManager(); | ||
| 139 | + manager.reload(active); | ||
| 140 | + const busId = service(manager, place(manager, "gare du nord", "BUS", "Paris").placeId, "Hôpital Européen").serviceId; | ||
| 141 | + const removedId = service(manager, place(manager, "chatelet", "METRO").placeId, "Porte de Clignancourt").serviceId; | ||
| 142 | + | ||
| 143 | + const second = join(directory, "second.sqlite"); | ||
| 144 | + await buildCatalogCandidateFromRecords({ candidatePath: second, sourceRevision: "fixture-2026-08-b", sources: sourcesFor("fixture-2026-08-b") }); | ||
| 145 | + activateCatalogCandidate(second, active); | ||
| 146 | + assert.equal(manager.resolveService(removedId).status, "RESOLVED"); | ||
| 147 | + manager.reload(active); | ||
| 148 | + assert.equal(service(manager, place(manager, "gare du nord", "BUS", "Paris").placeId, "Hôpital Européen").serviceId, busId); | ||
| 149 | + assert.deepEqual(manager.resolveService(removedId), { status: "UNRESOLVED", code: "INVALID_SERVICE" }); | ||
| 150 | + | ||
| 151 | + const before = createHash("sha256").update(readFileSync(active)).digest("hex"); | ||
| 152 | + const invalid = join(directory, "invalid.sqlite"); | ||
| 153 | + await assert.rejects(refreshCatalogFromRecords({ candidatePath: invalid, activePath: active, sourceRevision: "fixture-invalid-no-transilien", sources: sourcesFor("fixture-invalid-no-transilien") }), /no resolvable TRANSILIEN/u); | ||
| 154 | + assert.equal(existsSync(invalid), false); | ||
| 155 | + assert.equal(createHash("sha256").update(readFileSync(active)).digest("hex"), before); | ||
| 156 | + assert.equal(manager.resolveService(busId).status, "RESOLVED"); | ||
| 157 | +}); | ||
| 158 | + | ||
| 159 | +test("restricted source download streams bytes and puts its token only in Authorization", async (t) => { | ||
| 160 | + const directory = workspace(t); | ||
| 161 | + const destination = join(directory, "source.csv"); | ||
| 162 | + const bytes = new TextEncoder().encode("id;name\n1;Châtelet\n"); | ||
| 163 | + let requested = ""; | ||
| 164 | + let authorization: string | null = null; | ||
| 165 | + let redirect: RequestRedirect | undefined; | ||
| 166 | + const fetchSource = (async (input: string | URL | Request, init?: RequestInit) => { | ||
| 167 | + requested = String(input); | ||
| 168 | + authorization = new Headers(init?.headers).get("Authorization"); | ||
| 169 | + redirect = init?.redirect; | ||
| 170 | + return new Response(bytes, { status: 200, headers: { "content-type": "text/csv; charset=utf-8" } }); | ||
| 171 | + }) as typeof globalThis.fetch; | ||
| 172 | + const result = await downloadCatalogSource({ | ||
| 173 | + source: { dataset: "restricted-fixture", url: "https://data.iledefrance-mobilites.fr/source.csv", retrievedAt: "2026-08-30T00:00:00.000Z", license: "Licence Mobilité", restricted: true }, | ||
| 174 | + destinationPath: destination, | ||
| 175 | + expectedContentTypes: ["text/csv"], | ||
| 176 | + datasetToken: "operator-test-value", | ||
| 177 | + fetch: fetchSource, | ||
| 178 | + }); | ||
| 179 | + assert.equal(requested.includes("operator-test-value"), false); | ||
| 180 | + assert.equal(authorization, "apikey operator-test-value"); | ||
| 181 | + assert.equal(redirect, "manual"); | ||
| 182 | + assert.equal(result.bytes, bytes.byteLength); | ||
| 183 | + assert.equal(result.sha256, createHash("sha256").update(bytes).digest("hex")); | ||
| 184 | + assert.deepEqual(readFileSync(destination), Buffer.from(bytes)); | ||
| 185 | +}); | ||
added
packages/backend/test/catalog.test.ts +467 -0 Large diff collapsed to keep the page fast — load it when you need it.
modified
packages/companion/src/contracts.js +4 -1 | @@ -8,7 +8,10 @@ var LIMITS = Object.freeze({ | ||
| 8 | 8 | favorites: 8, |
| 9 | 9 | departures: 4, |
| 10 | 10 | httpTimeoutMs: 8000, |
| 11 | - httpResponseBytes: 262144 | |
| 11 | + httpResponseBytes: 262144, | |
| 12 | + catalogQueryMinCharacters: 2, | |
| 13 | + catalogQueryMaxCharacters: 100, | |
| 14 | + catalogSearchResults: 20 | |
| 12 | 15 | }); |
| 13 | 16 | var CACHE_FRESH_SECONDS = 60; |
| 14 | 17 | var FAVORITE_SETTLE_MS = 500; |
| @@ -8,7 +8,10 @@ var LIMITS = Object.freeze({ | |||
| 8 | favorites: 8, | 8 | favorites: 8, |
| 9 | departures: 4, | 9 | departures: 4, |
| 10 | httpTimeoutMs: 8000, | 10 | httpTimeoutMs: 8000, |
| 11 | - httpResponseBytes: 262144 | 11 | + httpResponseBytes: 262144, |
| 12 | + catalogQueryMinCharacters: 2, | ||
| 13 | + catalogQueryMaxCharacters: 100, | ||
| 14 | + catalogSearchResults: 20 | ||
| 12 | }); | 15 | }); |
| 13 | var CACHE_FRESH_SECONDS = 60; | 16 | var CACHE_FRESH_SECONDS = 60; |
| 14 | var FAVORITE_SETTLE_MS = 500; | 17 | var FAVORITE_SETTLE_MS = 500; |
modified
packages/contracts/src/index.ts +80 -0 | @@ -8,6 +8,9 @@ export const LIMITS = { | ||
| 8 | 8 | departures: 4, |
| 9 | 9 | httpTimeoutMs: 8_000, |
| 10 | 10 | httpResponseBytes: 262_144, |
| 11 | + catalogQueryMinCharacters: 2, | |
| 12 | + catalogQueryMaxCharacters: 100, | |
| 13 | + catalogSearchResults: 20, | |
| 11 | 14 | } as const; |
| 12 | 15 | |
| 13 | 16 | export const PRIM_ORIGIN = "https://prim.iledefrance-mobilites.fr" as const; |
| @@ -24,6 +27,9 @@ export const ERROR_CODE = [ | ||
| 24 | 27 | "RATE_LIMITED", |
| 25 | 28 | "INVALID_RESPONSE", |
| 26 | 29 | ] as const; |
| 30 | +export const TRANSPORT_MODE = ["BUS", "METRO", "TRAM", "RER", "TRANSILIEN"] as const; | |
| 31 | +export const CATALOG_ERROR_CODE = ["INVALID_QUERY", "PLACE_NOT_FOUND", "CATALOG_UNAVAILABLE", "METHOD_NOT_ALLOWED"] as const; | |
| 32 | +export const CATALOG_ROUTE = { places: "/api/catalog/places", placeServices: "/api/catalog/places/:placeId/services" } as const; | |
| 27 | 33 | export const API_KEY_ACTION = ["KEEP", "REPLACE", "REMOVE"] as const; |
| 28 | 34 | export const KEY_STATUS = { MISSING: 0, CONFIGURED: 1, INVALID: 2 } as const; |
| 29 | 35 | export const REQUEST_TRIGGER = { APP_OPEN: 0, FAVORITE_SELECTION: 1, MANUAL_SELECT: 2 } as const; |
| @@ -97,6 +103,8 @@ export const WIRE_LANGUAGE = { EN: "en", FR: "fr" } as const; | ||
| 97 | 103 | export type Freshness = (typeof FRESHNESS)[number]; |
| 98 | 104 | export type DepartureStatus = (typeof DEPARTURE_STATUS)[number]; |
| 99 | 105 | export type ErrorCode = (typeof ERROR_CODE)[number]; |
| 106 | +export type TransportMode = (typeof TRANSPORT_MODE)[number]; | |
| 107 | +export type CatalogErrorCode = (typeof CATALOG_ERROR_CODE)[number]; | |
| 100 | 108 | export type ApiKeyAction = (typeof API_KEY_ACTION)[number]; |
| 101 | 109 | export type AppMessageKey = (typeof APP_MESSAGE_KEY_ORDER)[number]; |
| 102 | 110 | export type WireLanguage = (typeof WIRE_LANGUAGE)[keyof typeof WIRE_LANGUAGE]; |
| @@ -138,6 +146,35 @@ export interface ErrorResult { | ||
| 138 | 146 | occurredAt: number; |
| 139 | 147 | retryAfterSeconds?: number; |
| 140 | 148 | } |
| 149 | +export interface PlaceSearchItem { | |
| 150 | + placeId: string; | |
| 151 | + stopLabel: string; | |
| 152 | + localityLabel?: string; | |
| 153 | + mode: TransportMode; | |
| 154 | +} | |
| 155 | + | |
| 156 | +export interface PlaceSearchResult { | |
| 157 | + schemaVersion: typeof SCHEMA_VERSION; | |
| 158 | + places: PlaceSearchItem[]; | |
| 159 | +} | |
| 160 | + | |
| 161 | +export interface ServiceOption { | |
| 162 | + serviceId: string; | |
| 163 | + stopLabel: string; | |
| 164 | + lineLabel: string; | |
| 165 | + destinationLabel: string; | |
| 166 | +} | |
| 167 | + | |
| 168 | +export interface ServiceOptionsResult { | |
| 169 | + schemaVersion: typeof SCHEMA_VERSION; | |
| 170 | + placeId: string; | |
| 171 | + services: ServiceOption[]; | |
| 172 | +} | |
| 173 | + | |
| 174 | +export interface CatalogErrorResult { | |
| 175 | + schemaVersion: typeof SCHEMA_VERSION; | |
| 176 | + code: CatalogErrorCode; | |
| 177 | +} | |
| 141 | 178 | |
| 142 | 179 | export type ApiKeyUpdate = |
| 143 | 180 | | { schemaVersion: typeof SCHEMA_VERSION; action: "KEEP" } |
| @@ -155,6 +192,11 @@ const domainKeys = { | ||
| 155 | 192 | result: ["schemaVersion", "requestId", "favoriteId", "fetchedAt", "sourceUpdatedAt", "freshness", "departures"], |
| 156 | 193 | error: ["schemaVersion", "requestId", "favoriteId", "code", "occurredAt", "retryAfterSeconds"], |
| 157 | 194 | keyUpdate: ["schemaVersion", "action", "value"], |
| 195 | + placeSearchItem: ["placeId", "stopLabel", "localityLabel", "mode"], | |
| 196 | + placeSearchResult: ["schemaVersion", "places"], | |
| 197 | + serviceOption: ["serviceId", "stopLabel", "lineLabel", "destinationLabel"], | |
| 198 | + serviceOptionsResult: ["schemaVersion", "placeId", "services"], | |
| 199 | + catalogError: ["schemaVersion", "code"], | |
| 158 | 200 | } as const; |
| 159 | 201 | |
| 160 | 202 | function object(value: unknown): value is Record<string, unknown> { |
| @@ -234,6 +276,44 @@ export function isErrorResult(value: unknown): value is ErrorResult { | ||
| 234 | 276 | && optionalUint32(value.retryAfterSeconds); |
| 235 | 277 | } |
| 236 | 278 | |
| 279 | +export function isPlaceSearchItem(value: unknown): value is PlaceSearchItem { | |
| 280 | + if (!object(value) || !exactKeys(value, domainKeys.placeSearchItem)) return false; | |
| 281 | + return boundedString(value.placeId, LIMITS.idUtf8Bytes) | |
| 282 | + && boundedString(value.stopLabel, LIMITS.labelUtf8Bytes) | |
| 283 | + && optionalBoundedString(value.localityLabel, LIMITS.labelUtf8Bytes) | |
| 284 | + && TRANSPORT_MODE.includes(value.mode as TransportMode); | |
| 285 | +} | |
| 286 | + | |
| 287 | +export function isPlaceSearchResult(value: unknown): value is PlaceSearchResult { | |
| 288 | + if (!object(value) || !exactKeys(value, domainKeys.placeSearchResult)) return false; | |
| 289 | + return value.schemaVersion === SCHEMA_VERSION | |
| 290 | + && Array.isArray(value.places) | |
| 291 | + && value.places.length <= LIMITS.catalogSearchResults | |
| 292 | + && value.places.every(isPlaceSearchItem); | |
| 293 | +} | |
| 294 | + | |
| 295 | +export function isServiceOption(value: unknown): value is ServiceOption { | |
| 296 | + if (!object(value) || !exactKeys(value, domainKeys.serviceOption)) return false; | |
| 297 | + return boundedString(value.serviceId, LIMITS.idUtf8Bytes) | |
| 298 | + && boundedString(value.stopLabel, LIMITS.labelUtf8Bytes) | |
| 299 | + && boundedString(value.lineLabel, LIMITS.labelUtf8Bytes) | |
| 300 | + && boundedString(value.destinationLabel, LIMITS.labelUtf8Bytes); | |
| 301 | +} | |
| 302 | + | |
| 303 | +export function isServiceOptionsResult(value: unknown): value is ServiceOptionsResult { | |
| 304 | + if (!object(value) || !exactKeys(value, domainKeys.serviceOptionsResult)) return false; | |
| 305 | + return value.schemaVersion === SCHEMA_VERSION | |
| 306 | + && boundedString(value.placeId, LIMITS.idUtf8Bytes) | |
| 307 | + && Array.isArray(value.services) | |
| 308 | + && value.services.every(isServiceOption); | |
| 309 | +} | |
| 310 | + | |
| 311 | +export function isCatalogErrorResult(value: unknown): value is CatalogErrorResult { | |
| 312 | + if (!object(value) || !exactKeys(value, domainKeys.catalogError)) return false; | |
| 313 | + return value.schemaVersion === SCHEMA_VERSION | |
| 314 | + && CATALOG_ERROR_CODE.includes(value.code as CatalogErrorCode); | |
| 315 | +} | |
| 316 | + | |
| 237 | 317 | export function isApiKeyUpdate(value: unknown): value is ApiKeyUpdate { |
| 238 | 318 | if (!object(value) || !exactKeys(value, domainKeys.keyUpdate) || value.schemaVersion !== SCHEMA_VERSION) return false; |
| 239 | 319 | if (value.action === "REPLACE") return boundedString(value.value, LIMITS.apiKeyUtf8Bytes); |
| @@ -8,6 +8,9 @@ export const LIMITS = { | |||
| 8 | departures: 4, | 8 | departures: 4, |
| 9 | httpTimeoutMs: 8_000, | 9 | httpTimeoutMs: 8_000, |
| 10 | httpResponseBytes: 262_144, | 10 | httpResponseBytes: 262_144, |
| 11 | + catalogQueryMinCharacters: 2, | ||
| 12 | + catalogQueryMaxCharacters: 100, | ||
| 13 | + catalogSearchResults: 20, | ||
| 11 | } as const; | 14 | } as const; |
| 12 | 15 | ||
| 13 | export const PRIM_ORIGIN = "https://prim.iledefrance-mobilites.fr" as const; | 16 | export const PRIM_ORIGIN = "https://prim.iledefrance-mobilites.fr" as const; |
| @@ -24,6 +27,9 @@ export const ERROR_CODE = [ | |||
| 24 | "RATE_LIMITED", | 27 | "RATE_LIMITED", |
| 25 | "INVALID_RESPONSE", | 28 | "INVALID_RESPONSE", |
| 26 | ] as const; | 29 | ] as const; |
| 30 | +export const TRANSPORT_MODE = ["BUS", "METRO", "TRAM", "RER", "TRANSILIEN"] as const; | ||
| 31 | +export const CATALOG_ERROR_CODE = ["INVALID_QUERY", "PLACE_NOT_FOUND", "CATALOG_UNAVAILABLE", "METHOD_NOT_ALLOWED"] as const; | ||
| 32 | +export const CATALOG_ROUTE = { places: "/api/catalog/places", placeServices: "/api/catalog/places/:placeId/services" } as const; | ||
| 27 | export const API_KEY_ACTION = ["KEEP", "REPLACE", "REMOVE"] as const; | 33 | export const API_KEY_ACTION = ["KEEP", "REPLACE", "REMOVE"] as const; |
| 28 | export const KEY_STATUS = { MISSING: 0, CONFIGURED: 1, INVALID: 2 } as const; | 34 | export const KEY_STATUS = { MISSING: 0, CONFIGURED: 1, INVALID: 2 } as const; |
| 29 | export const REQUEST_TRIGGER = { APP_OPEN: 0, FAVORITE_SELECTION: 1, MANUAL_SELECT: 2 } as const; | 35 | export const REQUEST_TRIGGER = { APP_OPEN: 0, FAVORITE_SELECTION: 1, MANUAL_SELECT: 2 } as const; |
| @@ -97,6 +103,8 @@ export const WIRE_LANGUAGE = { EN: "en", FR: "fr" } as const; | |||
| 97 | export type Freshness = (typeof FRESHNESS)[number]; | 103 | export type Freshness = (typeof FRESHNESS)[number]; |
| 98 | export type DepartureStatus = (typeof DEPARTURE_STATUS)[number]; | 104 | export type DepartureStatus = (typeof DEPARTURE_STATUS)[number]; |
| 99 | export type ErrorCode = (typeof ERROR_CODE)[number]; | 105 | export type ErrorCode = (typeof ERROR_CODE)[number]; |
| 106 | +export type TransportMode = (typeof TRANSPORT_MODE)[number]; | ||
| 107 | +export type CatalogErrorCode = (typeof CATALOG_ERROR_CODE)[number]; | ||
| 100 | export type ApiKeyAction = (typeof API_KEY_ACTION)[number]; | 108 | export type ApiKeyAction = (typeof API_KEY_ACTION)[number]; |
| 101 | export type AppMessageKey = (typeof APP_MESSAGE_KEY_ORDER)[number]; | 109 | export type AppMessageKey = (typeof APP_MESSAGE_KEY_ORDER)[number]; |
| 102 | export type WireLanguage = (typeof WIRE_LANGUAGE)[keyof typeof WIRE_LANGUAGE]; | 110 | export type WireLanguage = (typeof WIRE_LANGUAGE)[keyof typeof WIRE_LANGUAGE]; |
| @@ -138,6 +146,35 @@ export interface ErrorResult { | |||
| 138 | occurredAt: number; | 146 | occurredAt: number; |
| 139 | retryAfterSeconds?: number; | 147 | retryAfterSeconds?: number; |
| 140 | } | 148 | } |
| 149 | +export interface PlaceSearchItem { | ||
| 150 | + placeId: string; | ||
| 151 | + stopLabel: string; | ||
| 152 | + localityLabel?: string; | ||
| 153 | + mode: TransportMode; | ||
| 154 | +} | ||
| 155 | + | ||
| 156 | +export interface PlaceSearchResult { | ||
| 157 | + schemaVersion: typeof SCHEMA_VERSION; | ||
| 158 | + places: PlaceSearchItem[]; | ||
| 159 | +} | ||
| 160 | + | ||
| 161 | +export interface ServiceOption { | ||
| 162 | + serviceId: string; | ||
| 163 | + stopLabel: string; | ||
| 164 | + lineLabel: string; | ||
| 165 | + destinationLabel: string; | ||
| 166 | +} | ||
| 167 | + | ||
| 168 | +export interface ServiceOptionsResult { | ||
| 169 | + schemaVersion: typeof SCHEMA_VERSION; | ||
| 170 | + placeId: string; | ||
| 171 | + services: ServiceOption[]; | ||
| 172 | +} | ||
| 173 | + | ||
| 174 | +export interface CatalogErrorResult { | ||
| 175 | + schemaVersion: typeof SCHEMA_VERSION; | ||
| 176 | + code: CatalogErrorCode; | ||
| 177 | +} | ||
| 141 | 178 | ||
| 142 | export type ApiKeyUpdate = | 179 | export type ApiKeyUpdate = |
| 143 | | { schemaVersion: typeof SCHEMA_VERSION; action: "KEEP" } | 180 | | { schemaVersion: typeof SCHEMA_VERSION; action: "KEEP" } |
| @@ -155,6 +192,11 @@ const domainKeys = { | |||
| 155 | result: ["schemaVersion", "requestId", "favoriteId", "fetchedAt", "sourceUpdatedAt", "freshness", "departures"], | 192 | result: ["schemaVersion", "requestId", "favoriteId", "fetchedAt", "sourceUpdatedAt", "freshness", "departures"], |
| 156 | error: ["schemaVersion", "requestId", "favoriteId", "code", "occurredAt", "retryAfterSeconds"], | 193 | error: ["schemaVersion", "requestId", "favoriteId", "code", "occurredAt", "retryAfterSeconds"], |
| 157 | keyUpdate: ["schemaVersion", "action", "value"], | 194 | keyUpdate: ["schemaVersion", "action", "value"], |
| 195 | + placeSearchItem: ["placeId", "stopLabel", "localityLabel", "mode"], | ||
| 196 | + placeSearchResult: ["schemaVersion", "places"], | ||
| 197 | + serviceOption: ["serviceId", "stopLabel", "lineLabel", "destinationLabel"], | ||
| 198 | + serviceOptionsResult: ["schemaVersion", "placeId", "services"], | ||
| 199 | + catalogError: ["schemaVersion", "code"], | ||
| 158 | } as const; | 200 | } as const; |
| 159 | 201 | ||
| 160 | function object(value: unknown): value is Record<string, unknown> { | 202 | function object(value: unknown): value is Record<string, unknown> { |
| @@ -234,6 +276,44 @@ export function isErrorResult(value: unknown): value is ErrorResult { | |||
| 234 | && optionalUint32(value.retryAfterSeconds); | 276 | && optionalUint32(value.retryAfterSeconds); |
| 235 | } | 277 | } |
| 236 | 278 | ||
| 279 | +export function isPlaceSearchItem(value: unknown): value is PlaceSearchItem { | ||
| 280 | + if (!object(value) || !exactKeys(value, domainKeys.placeSearchItem)) return false; | ||
| 281 | + return boundedString(value.placeId, LIMITS.idUtf8Bytes) | ||
| 282 | + && boundedString(value.stopLabel, LIMITS.labelUtf8Bytes) | ||
| 283 | + && optionalBoundedString(value.localityLabel, LIMITS.labelUtf8Bytes) | ||
| 284 | + && TRANSPORT_MODE.includes(value.mode as TransportMode); | ||
| 285 | +} | ||
| 286 | + | ||
| 287 | +export function isPlaceSearchResult(value: unknown): value is PlaceSearchResult { | ||
| 288 | + if (!object(value) || !exactKeys(value, domainKeys.placeSearchResult)) return false; | ||
| 289 | + return value.schemaVersion === SCHEMA_VERSION | ||
| 290 | + && Array.isArray(value.places) | ||
| 291 | + && value.places.length <= LIMITS.catalogSearchResults | ||
| 292 | + && value.places.every(isPlaceSearchItem); | ||
| 293 | +} | ||
| 294 | + | ||
| 295 | +export function isServiceOption(value: unknown): value is ServiceOption { | ||
| 296 | + if (!object(value) || !exactKeys(value, domainKeys.serviceOption)) return false; | ||
| 297 | + return boundedString(value.serviceId, LIMITS.idUtf8Bytes) | ||
| 298 | + && boundedString(value.stopLabel, LIMITS.labelUtf8Bytes) | ||
| 299 | + && boundedString(value.lineLabel, LIMITS.labelUtf8Bytes) | ||
| 300 | + && boundedString(value.destinationLabel, LIMITS.labelUtf8Bytes); | ||
| 301 | +} | ||
| 302 | + | ||
| 303 | +export function isServiceOptionsResult(value: unknown): value is ServiceOptionsResult { | ||
| 304 | + if (!object(value) || !exactKeys(value, domainKeys.serviceOptionsResult)) return false; | ||
| 305 | + return value.schemaVersion === SCHEMA_VERSION | ||
| 306 | + && boundedString(value.placeId, LIMITS.idUtf8Bytes) | ||
| 307 | + && Array.isArray(value.services) | ||
| 308 | + && value.services.every(isServiceOption); | ||
| 309 | +} | ||
| 310 | + | ||
| 311 | +export function isCatalogErrorResult(value: unknown): value is CatalogErrorResult { | ||
| 312 | + if (!object(value) || !exactKeys(value, domainKeys.catalogError)) return false; | ||
| 313 | + return value.schemaVersion === SCHEMA_VERSION | ||
| 314 | + && CATALOG_ERROR_CODE.includes(value.code as CatalogErrorCode); | ||
| 315 | +} | ||
| 316 | + | ||
| 237 | export function isApiKeyUpdate(value: unknown): value is ApiKeyUpdate { | 317 | export function isApiKeyUpdate(value: unknown): value is ApiKeyUpdate { |
| 238 | if (!object(value) || !exactKeys(value, domainKeys.keyUpdate) || value.schemaVersion !== SCHEMA_VERSION) return false; | 318 | if (!object(value) || !exactKeys(value, domainKeys.keyUpdate) || value.schemaVersion !== SCHEMA_VERSION) return false; |
| 239 | if (value.action === "REPLACE") return boundedString(value.value, LIMITS.apiKeyUtf8Bytes); | 319 | if (value.action === "REPLACE") return boundedString(value.value, LIMITS.apiKeyUtf8Bytes); |
modified
packages/contracts/test/contracts.test.ts +99 -0 Large diff collapsed to keep the page fast — load it when you need it.
added
scripts/measure-catalog.mjs +262 -0 Large diff collapsed to keep the page fast — load it when you need it.
added
scripts/probe-catalog.mjs +275 -0 Large diff collapsed to keep the page fast — load it when you need it.
added
scripts/refresh-catalog.mjs +442 -0 Large diff collapsed to keep the page fast — load it when you need it.
modified
scripts/test.mjs +3 -0 | @@ -10,6 +10,9 @@ run(process.execPath, [ | ||
| 10 | 10 | "--test", |
| 11 | 11 | "packages/contracts/test/contracts.test.ts", |
| 12 | 12 | "packages/backend/test/backend.test.ts", |
| 13 | + "packages/backend/test/catalog.test.ts", | |
| 14 | + "packages/backend/test/catalog-import.test.ts", | |
| 15 | + "packages/backend/test/catalog-http.test.ts", | |
| 13 | 16 | "packages/config-page/test/config-page.test.ts", |
| 14 | 17 | "packages/companion/test/protocol.test.js", |
| 15 | 18 | "packages/companion/test/companion.test.js", |
| @@ -10,6 +10,9 @@ run(process.execPath, [ | |||
| 10 | "--test", | 10 | "--test", |
| 11 | "packages/contracts/test/contracts.test.ts", | 11 | "packages/contracts/test/contracts.test.ts", |
| 12 | "packages/backend/test/backend.test.ts", | 12 | "packages/backend/test/backend.test.ts", |
| 13 | + "packages/backend/test/catalog.test.ts", | ||
| 14 | + "packages/backend/test/catalog-import.test.ts", | ||
| 15 | + "packages/backend/test/catalog-http.test.ts", | ||
| 13 | "packages/config-page/test/config-page.test.ts", | 16 | "packages/config-page/test/config-page.test.ts", |
| 14 | "packages/companion/test/protocol.test.js", | 17 | "packages/companion/test/protocol.test.js", |
| 15 | "packages/companion/test/companion.test.js", | 18 | "packages/companion/test/companion.test.js", |