1use crate::error::Error;
2use crate::models::{
3 Area, Crime, CrimeCategory, CrimeLastUpdated, CrimeOutcomes, Force, ForceDetail, LatLng,
4 LocateNeighbourhoodResult, Neighbourhood, NeighbourhoodDetail, NeighbourhoodEvent,
5 NeighbourhoodPriority, Outcome, SeniorOfficer, StopAndSearch,
6};
7
8const BASE_URL: &str = "https://data.police.uk/api";
9
10#[derive(Clone)]
23pub struct Client {
24 http: reqwest::Client,
25 base_url: String,
26}
27
28impl Client {
29 async fn handle_response<T: serde::de::DeserializeOwned>(
30 response: reqwest::Response,
31 ) -> Result<T, Error> {
32 if !response.status().is_success() {
33 let status = response.status().as_u16();
34 let body = response.text().await.unwrap_or_default();
35 return Err(Error::Api { status, body });
36 }
37 Ok(response.json().await?)
38 }
39
40 fn area_query(area: &Area) -> String {
41 match area {
42 Area::Point(coord) => format!("lat={}&lng={}", coord.lat, coord.lng),
43 Area::Custom(coords) => {
44 let poly = coords
45 .iter()
46 .map(|c| format!("{},{}", c.lat, c.lng))
47 .collect::<Vec<_>>()
48 .join(":");
49 format!("poly={poly}")
50 }
51 Area::LocationId(id) => format!("location_id={id}"),
52 }
53 }
54
55 pub fn new() -> Self {
56 Self {
57 http: reqwest::Client::new(),
58 base_url: BASE_URL.to_string(),
59 }
60 }
61
62 pub fn from_http_client(http: reqwest::Client) -> Self {
77 Self {
78 http,
79 base_url: BASE_URL.to_string(),
80 }
81 }
82
83 pub async fn forces(&self) -> Result<Vec<Force>, Error> {
85 let url = format!("{}/forces", self.base_url);
86 let response = self.http.get(&url).send().await?;
87 Self::handle_response(response).await
88 }
89
90 pub async fn force(&self, id: &str) -> Result<ForceDetail, Error> {
92 let url = format!("{}/forces/{}", self.base_url, id);
93 let response = self.http.get(&url).send().await?;
94 Self::handle_response(response).await
95 }
96
97 pub async fn crime_categories(&self, date: Option<&str>) -> Result<Vec<CrimeCategory>, Error> {
99 let mut url = format!("{}/crime-categories", self.base_url);
100 if let Some(date) = date {
101 url.push_str(&format!("?date={date}"));
102 }
103 let response = self.http.get(&url).send().await?;
104 Self::handle_response(response).await
105 }
106
107 pub async fn street_level_crimes(
115 &self,
116 category: &str,
117 area: &Area,
118 date: Option<&str>,
119 ) -> Result<Vec<Crime>, Error> {
120 let mut url = format!(
121 "{}/crimes-street/{}?{}",
122 self.base_url,
123 category,
124 Self::area_query(area)
125 );
126 if let Some(date) = date {
127 url.push_str(&format!("&date={date}"));
128 }
129 let response = self.http.get(&url).send().await?;
130 Self::handle_response(response).await
131 }
132
133 pub async fn street_level_outcomes(
140 &self,
141 area: &Area,
142 date: Option<&str>,
143 ) -> Result<Vec<Outcome>, Error> {
144 let mut url = format!(
145 "{}/outcomes-at-location?{}",
146 self.base_url,
147 Self::area_query(area)
148 );
149 if let Some(date) = date {
150 url.push_str(&format!("&date={date}"));
151 }
152 let response = self.http.get(&url).send().await?;
153 Self::handle_response(response).await
154 }
155
156 pub async fn crime_last_updated(&self) -> Result<CrimeLastUpdated, Error> {
158 let url = format!("{}/crime-last-updated", self.base_url);
159 let response = self.http.get(&url).send().await?;
160 Self::handle_response(response).await
161 }
162
163 pub async fn senior_officers(&self, force_id: &str) -> Result<Vec<SeniorOfficer>, Error> {
165 let url = format!("{}/forces/{}/people", self.base_url, force_id);
166 let response = self.http.get(&url).send().await?;
167 Self::handle_response(response).await
168 }
169
170 pub async fn crimes_at_location(
177 &self,
178 location_id: u64,
179 date: Option<&str>,
180 ) -> Result<Vec<Crime>, Error> {
181 let mut url = format!(
182 "{}/crimes-at-location?location_id={}",
183 self.base_url, location_id
184 );
185 if let Some(date) = date {
186 url.push_str(&format!("&date={date}"));
187 }
188 let response = self.http.get(&url).send().await?;
189 Self::handle_response(response).await
190 }
191
192 pub async fn crimes_no_location(
200 &self,
201 category: &str,
202 force: &str,
203 date: Option<&str>,
204 ) -> Result<Vec<Crime>, Error> {
205 let mut url = format!(
206 "{}/crimes-no-location?category={}&force={}",
207 self.base_url, category, force
208 );
209 if let Some(date) = date {
210 url.push_str(&format!("&date={date}"));
211 }
212 let response = self.http.get(&url).send().await?;
213 Self::handle_response(response).await
214 }
215
216 pub async fn outcomes_for_crime(&self, persistent_id: &str) -> Result<CrimeOutcomes, Error> {
222 let url = format!("{}/outcomes-for-crime/{}", self.base_url, persistent_id);
223 let response = self.http.get(&url).send().await?;
224 Self::handle_response(response).await
225 }
226
227 pub async fn neighbourhoods(&self, force_id: &str) -> Result<Vec<Neighbourhood>, Error> {
229 let url = format!("{}/{}/neighbourhoods", self.base_url, force_id);
230 let response = self.http.get(&url).send().await?;
231 Self::handle_response(response).await
232 }
233
234 pub async fn neighbourhood(
236 &self,
237 force_id: &str,
238 neighbourhood_id: &str,
239 ) -> Result<NeighbourhoodDetail, Error> {
240 let url = format!("{}/{}/{}", self.base_url, force_id, neighbourhood_id);
241 let response = self.http.get(&url).send().await?;
242 Self::handle_response(response).await
243 }
244
245 pub async fn neighbourhood_boundary(
247 &self,
248 force_id: &str,
249 neighbourhood_id: &str,
250 ) -> Result<Vec<LatLng>, Error> {
251 let url = format!(
252 "{}/{}/{}/boundary",
253 self.base_url, force_id, neighbourhood_id
254 );
255 let response = self.http.get(&url).send().await?;
256 Self::handle_response(response).await
257 }
258
259 pub async fn neighbourhood_team(
261 &self,
262 force_id: &str,
263 neighbourhood_id: &str,
264 ) -> Result<Vec<SeniorOfficer>, Error> {
265 let url = format!("{}/{}/{}/people", self.base_url, force_id, neighbourhood_id);
266 let response = self.http.get(&url).send().await?;
267 Self::handle_response(response).await
268 }
269
270 pub async fn neighbourhood_events(
272 &self,
273 force_id: &str,
274 neighbourhood_id: &str,
275 ) -> Result<Vec<NeighbourhoodEvent>, Error> {
276 let url = format!("{}/{}/{}/events", self.base_url, force_id, neighbourhood_id);
277 let response = self.http.get(&url).send().await?;
278 Self::handle_response(response).await
279 }
280
281 pub async fn neighbourhood_priorities(
283 &self,
284 force_id: &str,
285 neighbourhood_id: &str,
286 ) -> Result<Vec<NeighbourhoodPriority>, Error> {
287 let url = format!(
288 "{}/{}/{}/priorities",
289 self.base_url, force_id, neighbourhood_id
290 );
291 let response = self.http.get(&url).send().await?;
292 Self::handle_response(response).await
293 }
294
295 pub async fn locate_neighbourhood(
297 &self,
298 lat: f64,
299 lng: f64,
300 ) -> Result<LocateNeighbourhoodResult, Error> {
301 let url = format!("{}/locate-neighbourhood?q={},{}", self.base_url, lat, lng);
302 let response = self.http.get(&url).send().await?;
303 Self::handle_response(response).await
304 }
305
306 pub async fn stops_street(
313 &self,
314 area: &Area,
315 date: Option<&str>,
316 ) -> Result<Vec<StopAndSearch>, Error> {
317 let mut url = format!("{}/stops-street?{}", self.base_url, Self::area_query(area));
318 if let Some(date) = date {
319 url.push_str(&format!("&date={date}"));
320 }
321 let response = self.http.get(&url).send().await?;
322 Self::handle_response(response).await
323 }
324
325 pub async fn stops_at_location(
332 &self,
333 location_id: u64,
334 date: Option<&str>,
335 ) -> Result<Vec<StopAndSearch>, Error> {
336 let mut url = format!(
337 "{}/stops-at-location?location_id={}",
338 self.base_url, location_id
339 );
340 if let Some(date) = date {
341 url.push_str(&format!("&date={date}"));
342 }
343 let response = self.http.get(&url).send().await?;
344 Self::handle_response(response).await
345 }
346
347 pub async fn stops_no_location(
354 &self,
355 force: &str,
356 date: Option<&str>,
357 ) -> Result<Vec<StopAndSearch>, Error> {
358 let mut url = format!("{}/stops-no-location?force={}", self.base_url, force);
359 if let Some(date) = date {
360 url.push_str(&format!("&date={date}"));
361 }
362 let response = self.http.get(&url).send().await?;
363 Self::handle_response(response).await
364 }
365
366 pub async fn stops_force(
373 &self,
374 force: &str,
375 date: Option<&str>,
376 ) -> Result<Vec<StopAndSearch>, Error> {
377 let mut url = format!("{}/stops-force?force={}", self.base_url, force);
378 if let Some(date) = date {
379 url.push_str(&format!("&date={date}"));
380 }
381 let response = self.http.get(&url).send().await?;
382 Self::handle_response(response).await
383 }
384}
385
386impl Default for Client {
387 fn default() -> Self {
388 Self::new()
389 }
390}
391
392#[cfg(test)]
393mod tests {
394 use super::*;
395 use crate::models::Coordinate;
396 use wiremock::matchers::{method, path};
397 use wiremock::{Mock, MockServer, ResponseTemplate};
398
399 fn test_client(uri: &str) -> Client {
400 Client {
401 http: reqwest::Client::new(),
402 base_url: uri.to_string(),
403 }
404 }
405
406 #[tokio::test]
407 async fn test_forces() {
408 let server = MockServer::start().await;
409
410 Mock::given(method("GET"))
411 .and(path("/forces"))
412 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
413 { "id": "met", "name": "Metropolitan Police" },
414 { "id": "kent", "name": "Kent Police" }
415 ])))
416 .mount(&server)
417 .await;
418
419 let client = test_client(&server.uri());
420 let forces = client.forces().await.unwrap();
421
422 assert_eq!(forces.len(), 2);
423 assert_eq!(forces[0].id, "met");
424 assert_eq!(forces[1].name, "Kent Police");
425 }
426
427 #[tokio::test]
428 async fn test_force() {
429 let server = MockServer::start().await;
430
431 Mock::given(method("GET"))
432 .and(path("/forces/metropolitan"))
433 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
434 "id": "metropolitan",
435 "name": "Metropolitan Police Service",
436 "description": "The Met",
437 "url": "https://www.met.police.uk/",
438 "telephone": "101",
439 "engagement_methods": [
440 {
441 "type": "twitter",
442 "title": "twitter",
443 "description": null,
444 "url": "https://x.com/Metpoliceuk"
445 }
446 ]
447 })))
448 .mount(&server)
449 .await;
450
451 let client = test_client(&server.uri());
452 let force = client.force("metropolitan").await.unwrap();
453
454 assert_eq!(force.id, "metropolitan");
455 assert_eq!(force.telephone, Some("101".to_string()));
456 assert_eq!(force.engagement_methods.len(), 1);
457 assert_eq!(force.engagement_methods[0].kind, "twitter");
458 }
459
460 #[tokio::test]
461 async fn test_crime_categories() {
462 let server = MockServer::start().await;
463
464 Mock::given(method("GET"))
465 .and(path("/crime-categories"))
466 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
467 { "url": "burglary", "name": "Burglary" },
468 { "url": "drugs", "name": "Drugs" }
469 ])))
470 .mount(&server)
471 .await;
472
473 let client = test_client(&server.uri());
474 let categories = client.crime_categories(None).await.unwrap();
475
476 assert_eq!(categories.len(), 2);
477 assert_eq!(categories[0].url, "burglary");
478 }
479
480 fn mock_crime_json() -> serde_json::Value {
481 serde_json::json!([{
482 "category": "anti-social-behaviour",
483 "persistent_id": "",
484 "location_subtype": "",
485 "id": 116208998,
486 "location": {
487 "latitude": "52.632805",
488 "street": { "id": 1738842, "name": "On or near Campbell Street" },
489 "longitude": "-1.124819"
490 },
491 "context": "",
492 "month": "2024-01",
493 "location_type": "Force",
494 "outcome_status": {
495 "category": "Investigation complete; no suspect identified",
496 "date": "2024-01"
497 }
498 }])
499 }
500
501 #[tokio::test]
502 async fn test_street_level_crimes_by_point() {
503 let server = MockServer::start().await;
504
505 Mock::given(method("GET"))
506 .and(path("/crimes-street/all-crime"))
507 .respond_with(ResponseTemplate::new(200).set_body_json(mock_crime_json()))
508 .mount(&server)
509 .await;
510
511 let client = test_client(&server.uri());
512 let area = Area::Point(Coordinate {
513 lat: 52.629729,
514 lng: -1.131592,
515 });
516 let crimes = client
517 .street_level_crimes("all-crime", &area, Some("2024-01"))
518 .await
519 .unwrap();
520
521 assert_eq!(crimes.len(), 1);
522 assert_eq!(crimes[0].category, "anti-social-behaviour");
523 assert_eq!(
524 crimes[0].location.as_ref().unwrap().street.name,
525 "On or near Campbell Street"
526 );
527 assert_eq!(
528 crimes[0].outcome_status.as_ref().unwrap().category,
529 crate::models::OutcomeCategory::NoFurtherAction
530 );
531 }
532
533 #[tokio::test]
534 async fn test_street_level_crimes_by_area() {
535 let server = MockServer::start().await;
536
537 Mock::given(method("GET"))
538 .and(path("/crimes-street/all-crime"))
539 .respond_with(ResponseTemplate::new(200).set_body_json(mock_crime_json()))
540 .mount(&server)
541 .await;
542
543 let client = test_client(&server.uri());
544 let area = Area::Custom(vec![
545 Coordinate {
546 lat: 52.268,
547 lng: 0.543,
548 },
549 Coordinate {
550 lat: 52.794,
551 lng: 0.238,
552 },
553 Coordinate {
554 lat: 52.130,
555 lng: 0.478,
556 },
557 ]);
558 let crimes = client
559 .street_level_crimes("all-crime", &area, None)
560 .await
561 .unwrap();
562
563 assert_eq!(crimes.len(), 1);
564 assert_eq!(crimes[0].id, 116208998);
565 }
566
567 #[tokio::test]
568 async fn test_street_level_outcomes_by_location_id() {
569 let server = MockServer::start().await;
570
571 Mock::given(method("GET"))
572 .and(path("/outcomes-at-location"))
573 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
574 {
575 "category": {
576 "code": "local-resolution",
577 "name": "Local resolution"
578 },
579 "date": "2024-01",
580 "person_id": null,
581 "crime": {
582 "category": "public-order",
583 "persistent_id": "dd6e56f90d1bdd7bc7482af17852369f263203d9a688fac42ec53bf48485d8f1",
584 "location_subtype": "ROAD",
585 "location_type": "Force",
586 "location": {
587 "latitude": "52.637146",
588 "street": { "id": 1737432, "name": "On or near Vaughan Street" },
589 "longitude": "-1.149381"
590 },
591 "context": "",
592 "month": "2024-01",
593 "id": 116202605
594 }
595 }
596 ])))
597 .mount(&server)
598 .await;
599
600 let client = test_client(&server.uri());
601 let outcomes = client
602 .street_level_outcomes(&Area::LocationId(1737432), Some("2024-01"))
603 .await
604 .unwrap();
605
606 assert_eq!(outcomes.len(), 1);
607 assert_eq!(
608 outcomes[0].category.code,
609 crate::models::OutcomeCategory::LocalResolution
610 );
611 assert_eq!(outcomes[0].crime.category, "public-order");
612 assert!(outcomes[0].person_id.is_none());
613 }
614
615 #[tokio::test]
616 async fn test_street_level_outcomes_by_point() {
617 let server = MockServer::start().await;
618
619 Mock::given(method("GET"))
620 .and(path("/outcomes-at-location"))
621 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([])))
622 .mount(&server)
623 .await;
624
625 let client = test_client(&server.uri());
626 let area = Area::Point(Coordinate {
627 lat: 52.629729,
628 lng: -1.131592,
629 });
630 let outcomes = client.street_level_outcomes(&area, None).await.unwrap();
631
632 assert!(outcomes.is_empty());
633 }
634
635 #[tokio::test]
636 async fn test_crime_last_updated() {
637 let server = MockServer::start().await;
638
639 Mock::given(method("GET"))
640 .and(path("/crime-last-updated"))
641 .respond_with(
642 ResponseTemplate::new(200)
643 .set_body_json(serde_json::json!({ "date": "2025-12-01" })),
644 )
645 .mount(&server)
646 .await;
647
648 let client = test_client(&server.uri());
649 let updated = client.crime_last_updated().await.unwrap();
650
651 assert_eq!(updated.date, "2025-12-01");
652 }
653
654 #[tokio::test]
655 async fn test_senior_officers() {
656 let server = MockServer::start().await;
657
658 Mock::given(method("GET"))
659 .and(path("/forces/metropolitan/people"))
660 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
661 {
662 "name": "Mark Rowley",
663 "rank": "Commissioner",
664 "bio": null,
665 "contact_details": {
666 "twitter": "https://x.com/metpoliceuk"
667 }
668 }
669 ])))
670 .mount(&server)
671 .await;
672
673 let client = test_client(&server.uri());
674 let officers = client.senior_officers("metropolitan").await.unwrap();
675
676 assert_eq!(officers.len(), 1);
677 assert_eq!(officers[0].name, "Mark Rowley");
678 assert_eq!(officers[0].rank, "Commissioner");
679 assert!(officers[0].bio.is_none());
680 assert_eq!(
681 officers[0].contact_details.twitter,
682 Some("https://x.com/metpoliceuk".to_string())
683 );
684 }
685
686 #[tokio::test]
687 async fn test_crimes_at_location() {
688 let server = MockServer::start().await;
689
690 Mock::given(method("GET"))
691 .and(path("/crimes-at-location"))
692 .respond_with(ResponseTemplate::new(200).set_body_json(mock_crime_json()))
693 .mount(&server)
694 .await;
695
696 let client = test_client(&server.uri());
697 let crimes = client
698 .crimes_at_location(1738842, Some("2024-01"))
699 .await
700 .unwrap();
701
702 assert_eq!(crimes.len(), 1);
703 assert_eq!(crimes[0].category, "anti-social-behaviour");
704 }
705
706 #[tokio::test]
707 async fn test_crimes_no_location() {
708 let server = MockServer::start().await;
709
710 Mock::given(method("GET"))
711 .and(path("/crimes-no-location"))
712 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
713 {
714 "category": "burglary",
715 "persistent_id": "abc123",
716 "location_subtype": "",
717 "id": 999,
718 "location": null,
719 "context": "",
720 "month": "2024-01",
721 "location_type": null,
722 "outcome_status": null
723 }
724 ])))
725 .mount(&server)
726 .await;
727
728 let client = test_client(&server.uri());
729 let crimes = client
730 .crimes_no_location("burglary", "metropolitan", Some("2024-01"))
731 .await
732 .unwrap();
733
734 assert_eq!(crimes.len(), 1);
735 assert_eq!(crimes[0].category, "burglary");
736 assert!(crimes[0].location.is_none());
737 assert!(crimes[0].location_type.is_none());
738 }
739
740 #[tokio::test]
741 async fn test_outcomes_for_crime() {
742 let server = MockServer::start().await;
743
744 Mock::given(method("GET"))
745 .and(path(
746 "/outcomes-for-crime/dd6e56f90d1bdd7bc7482af17852369f263203d9a688fac42ec53bf48485d8f1",
747 ))
748 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
749 "crime": {
750 "category": "violent-crime",
751 "persistent_id": "dd6e56f90d1bdd7bc7482af17852369f263203d9a688fac42ec53bf48485d8f1",
752 "location_subtype": "",
753 "id": 116202605,
754 "location": {
755 "latitude": "52.637146",
756 "street": { "id": 1737432, "name": "On or near Vaughan Street" },
757 "longitude": "-1.149381"
758 },
759 "context": "",
760 "month": "2024-01",
761 "location_type": "Force",
762 "outcome_status": null
763 },
764 "outcomes": [
765 {
766 "category": {
767 "code": "no-further-action",
768 "name": "Investigation complete; no suspect identified"
769 },
770 "date": "2024-01",
771 "person_id": null
772 }
773 ]
774 })))
775 .mount(&server)
776 .await;
777
778 let client = test_client(&server.uri());
779 let result = client
780 .outcomes_for_crime("dd6e56f90d1bdd7bc7482af17852369f263203d9a688fac42ec53bf48485d8f1")
781 .await
782 .unwrap();
783
784 assert_eq!(result.crime.category, "violent-crime");
785 assert_eq!(result.outcomes.len(), 1);
786 assert_eq!(
787 result.outcomes[0].category.code,
788 crate::models::OutcomeCategory::NoFurtherAction
789 );
790 assert!(result.outcomes[0].person_id.is_none());
791 }
792
793 #[tokio::test]
794 async fn test_neighbourhoods() {
795 let server = MockServer::start().await;
796
797 Mock::given(method("GET"))
798 .and(path("/leicestershire/neighbourhoods"))
799 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
800 { "id": "NC04", "name": "City Centre" },
801 { "id": "NC66", "name": "Cultural Quarter" }
802 ])))
803 .mount(&server)
804 .await;
805
806 let client = test_client(&server.uri());
807 let neighbourhoods = client.neighbourhoods("leicestershire").await.unwrap();
808
809 assert_eq!(neighbourhoods.len(), 2);
810 assert_eq!(neighbourhoods[0].id, "NC04");
811 assert_eq!(neighbourhoods[1].name, "Cultural Quarter");
812 }
813
814 #[tokio::test]
815 async fn test_neighbourhood() {
816 let server = MockServer::start().await;
817
818 Mock::given(method("GET"))
819 .and(path("/leicestershire/NC04"))
820 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
821 "id": "NC04",
822 "name": "City Centre",
823 "description": "The city centre neighbourhood",
824 "population": "7985",
825 "url_force": "https://www.leics.police.uk/local-policing/city-centre",
826 "contact_details": {
827 "email": "citycentre@example.com"
828 },
829 "centre": {
830 "latitude": "52.6389",
831 "longitude": "-1.1350"
832 },
833 "links": [
834 { "url": "https://example.com", "title": "Example", "description": null }
835 ],
836 "locations": [
837 {
838 "name": "Mansfield House",
839 "latitude": "52.6352",
840 "longitude": "-1.1332",
841 "postcode": "LE1 3GG",
842 "address": "74 Belgrave Gate",
843 "telephone": "101",
844 "type": "station",
845 "description": null
846 }
847 ]
848 })))
849 .mount(&server)
850 .await;
851
852 let client = test_client(&server.uri());
853 let detail = client
854 .neighbourhood("leicestershire", "NC04")
855 .await
856 .unwrap();
857
858 assert_eq!(detail.id, "NC04");
859 assert_eq!(detail.population, Some("7985".to_string()));
860 assert_eq!(detail.centre.latitude, "52.6389");
861 assert_eq!(detail.links.len(), 1);
862 assert_eq!(detail.locations.len(), 1);
863 assert_eq!(detail.locations[0].kind, Some("station".to_string()));
864 }
865
866 #[tokio::test]
867 async fn test_neighbourhood_boundary() {
868 let server = MockServer::start().await;
869
870 Mock::given(method("GET"))
871 .and(path("/leicestershire/NC04/boundary"))
872 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
873 { "latitude": "52.6394", "longitude": "-1.1459" },
874 { "latitude": "52.6389", "longitude": "-1.1457" },
875 { "latitude": "52.6381", "longitude": "-1.1447" }
876 ])))
877 .mount(&server)
878 .await;
879
880 let client = test_client(&server.uri());
881 let boundary = client
882 .neighbourhood_boundary("leicestershire", "NC04")
883 .await
884 .unwrap();
885
886 assert_eq!(boundary.len(), 3);
887 assert_eq!(boundary[0].latitude, "52.6394");
888 assert_eq!(boundary[2].longitude, "-1.1447");
889 }
890
891 #[tokio::test]
892 async fn test_neighbourhood_team() {
893 let server = MockServer::start().await;
894
895 Mock::given(method("GET"))
896 .and(path("/leicestershire/NC04/people"))
897 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
898 {
899 "name": "Andy Cooper",
900 "rank": "Sgt",
901 "bio": "Andy has been with the force since 2003.",
902 "contact_details": {}
903 }
904 ])))
905 .mount(&server)
906 .await;
907
908 let client = test_client(&server.uri());
909 let team = client
910 .neighbourhood_team("leicestershire", "NC04")
911 .await
912 .unwrap();
913
914 assert_eq!(team.len(), 1);
915 assert_eq!(team[0].name, "Andy Cooper");
916 assert_eq!(team[0].rank, "Sgt");
917 assert_eq!(
918 team[0].bio,
919 Some("Andy has been with the force since 2003.".to_string())
920 );
921 }
922
923 #[tokio::test]
924 async fn test_neighbourhood_events() {
925 let server = MockServer::start().await;
926
927 Mock::given(method("GET"))
928 .and(path("/leicestershire/NC04/events"))
929 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
930 {
931 "title": "Bike Registration",
932 "description": "Free bike registration event",
933 "address": "Mansfield House",
934 "type": "meeting",
935 "start_date": "2024-09-17T17:00:00",
936 "end_date": "2024-09-17T19:00:00",
937 "contact_details": {}
938 }
939 ])))
940 .mount(&server)
941 .await;
942
943 let client = test_client(&server.uri());
944 let events = client
945 .neighbourhood_events("leicestershire", "NC04")
946 .await
947 .unwrap();
948
949 assert_eq!(events.len(), 1);
950 assert_eq!(events[0].title, Some("Bike Registration".to_string()));
951 assert_eq!(events[0].kind, Some("meeting".to_string()));
952 }
953
954 #[tokio::test]
955 async fn test_neighbourhood_priorities() {
956 let server = MockServer::start().await;
957
958 Mock::given(method("GET"))
959 .and(path("/leicestershire/NC04/priorities"))
960 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
961 {
962 "action": "Increased patrols in the area.",
963 "issue-date": "2024-07-01T00:00:00",
964 "action-date": "2024-09-01T00:00:00",
965 "issue": "Anti-social behaviour on Granby Street"
966 }
967 ])))
968 .mount(&server)
969 .await;
970
971 let client = test_client(&server.uri());
972 let priorities = client
973 .neighbourhood_priorities("leicestershire", "NC04")
974 .await
975 .unwrap();
976
977 assert_eq!(priorities.len(), 1);
978 assert_eq!(
979 priorities[0].issue,
980 Some("Anti-social behaviour on Granby Street".to_string())
981 );
982 assert_eq!(
983 priorities[0].action,
984 Some("Increased patrols in the area.".to_string())
985 );
986 }
987
988 #[tokio::test]
989 async fn test_locate_neighbourhood() {
990 let server = MockServer::start().await;
991
992 Mock::given(method("GET"))
993 .and(path("/locate-neighbourhood"))
994 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
995 "force": "metropolitan",
996 "neighbourhood": "E05013806N"
997 })))
998 .mount(&server)
999 .await;
1000
1001 let client = test_client(&server.uri());
1002 let result = client
1003 .locate_neighbourhood(51.500617, -0.124629)
1004 .await
1005 .unwrap();
1006
1007 assert_eq!(result.force, "metropolitan");
1008 assert_eq!(result.neighbourhood, "E05013806N");
1009 }
1010
1011 fn mock_stop_json() -> serde_json::Value {
1012 serde_json::json!([{
1013 "type": "Person search",
1014 "involved_person": true,
1015 "datetime": "2024-01-15T12:30:00+00:00",
1016 "operation": false,
1017 "operation_name": null,
1018 "location": {
1019 "latitude": "52.634407",
1020 "street": { "id": 1737432, "name": "On or near Vaughan Street" },
1021 "longitude": "-1.149381"
1022 },
1023 "gender": "Male",
1024 "age_range": "18-24",
1025 "self_defined_ethnicity": "White - English/Welsh/Scottish/Northern Irish/British",
1026 "officer_defined_ethnicity": "White",
1027 "legislation": "Misuse of Drugs Act 1971 (section 23)",
1028 "object_of_search": "Controlled drugs",
1029 "outcome": "A no further action disposal",
1030 "outcome_linked_to_object_of_search": null,
1031 "removal_of_more_than_outer_clothing": false
1032 }])
1033 }
1034
1035 #[tokio::test]
1036 async fn test_stops_street() {
1037 let server = MockServer::start().await;
1038
1039 Mock::given(method("GET"))
1040 .and(path("/stops-street"))
1041 .respond_with(ResponseTemplate::new(200).set_body_json(mock_stop_json()))
1042 .mount(&server)
1043 .await;
1044
1045 let client = test_client(&server.uri());
1046 let area = Area::Point(Coordinate {
1047 lat: 52.629729,
1048 lng: -1.131592,
1049 });
1050 let stops = client.stops_street(&area, Some("2024-01")).await.unwrap();
1051
1052 assert_eq!(stops.len(), 1);
1053 assert_eq!(
1054 stops[0].kind,
1055 Some(crate::models::StopAndSearchType::Person)
1056 );
1057 assert_eq!(stops[0].involved_person, Some(true));
1058 assert_eq!(stops[0].gender, Some("Male".to_string()));
1059 assert_eq!(
1060 stops[0].outcome,
1061 Some("A no further action disposal".to_string())
1062 );
1063 }
1064
1065 #[tokio::test]
1066 async fn test_stops_at_location() {
1067 let server = MockServer::start().await;
1068
1069 Mock::given(method("GET"))
1070 .and(path("/stops-at-location"))
1071 .respond_with(ResponseTemplate::new(200).set_body_json(mock_stop_json()))
1072 .mount(&server)
1073 .await;
1074
1075 let client = test_client(&server.uri());
1076 let stops = client
1077 .stops_at_location(1737432, Some("2024-01"))
1078 .await
1079 .unwrap();
1080
1081 assert_eq!(stops.len(), 1);
1082 assert_eq!(
1083 stops[0].object_of_search,
1084 Some("Controlled drugs".to_string())
1085 );
1086 }
1087
1088 #[tokio::test]
1089 async fn test_stops_no_location() {
1090 let server = MockServer::start().await;
1091
1092 Mock::given(method("GET"))
1093 .and(path("/stops-no-location"))
1094 .respond_with(
1095 ResponseTemplate::new(200).set_body_json(serde_json::json!([{
1096 "type": "Vehicle search",
1097 "involved_person": false,
1098 "datetime": "2024-01-10T08:00:00+00:00",
1099 "operation": null,
1100 "operation_name": null,
1101 "location": null,
1102 "gender": null,
1103 "age_range": null,
1104 "self_defined_ethnicity": null,
1105 "officer_defined_ethnicity": null,
1106 "legislation": "Misuse of Drugs Act 1971 (section 23)",
1107 "object_of_search": "Controlled drugs",
1108 "outcome": false,
1109 "outcome_linked_to_object_of_search": null,
1110 "removal_of_more_than_outer_clothing": null
1111 }])),
1112 )
1113 .mount(&server)
1114 .await;
1115
1116 let client = test_client(&server.uri());
1117 let stops = client
1118 .stops_no_location("leicestershire", Some("2024-01"))
1119 .await
1120 .unwrap();
1121
1122 assert_eq!(stops.len(), 1);
1123 assert_eq!(
1124 stops[0].kind,
1125 Some(crate::models::StopAndSearchType::Vehicle)
1126 );
1127 assert!(stops[0].location.is_none());
1128 assert!(stops[0].outcome.is_none());
1129 }
1130
1131 #[tokio::test]
1132 async fn test_stops_force() {
1133 let server = MockServer::start().await;
1134
1135 Mock::given(method("GET"))
1136 .and(path("/stops-force"))
1137 .respond_with(
1138 ResponseTemplate::new(200).set_body_json(serde_json::json!([{
1139 "type": "Person and Vehicle search",
1140 "involved_person": true,
1141 "datetime": "2024-01-20T14:00:00+00:00",
1142 "operation": true,
1143 "operation_name": "Operation Blitz",
1144 "location": {
1145 "latitude": "52.634407",
1146 "street": { "id": 1737432, "name": "On or near Vaughan Street" },
1147 "longitude": "-1.149381"
1148 },
1149 "gender": "Female",
1150 "age_range": "25-34",
1151 "self_defined_ethnicity": null,
1152 "officer_defined_ethnicity": "Black",
1153 "legislation": "Police and Criminal Evidence Act 1984 (section 1)",
1154 "object_of_search": "Stolen goods",
1155 "outcome": "Arrest",
1156 "outcome_object": {
1157 "id": "bu-arrest",
1158 "name": "Arrest"
1159 },
1160 "outcome_linked_to_object_of_search": true,
1161 "removal_of_more_than_outer_clothing": false
1162 }])),
1163 )
1164 .mount(&server)
1165 .await;
1166
1167 let client = test_client(&server.uri());
1168 let stops = client
1169 .stops_force("leicestershire", Some("2024-01"))
1170 .await
1171 .unwrap();
1172
1173 assert_eq!(stops.len(), 1);
1174 assert_eq!(
1175 stops[0].kind,
1176 Some(crate::models::StopAndSearchType::PersonAndVehicle)
1177 );
1178 assert_eq!(stops[0].operation, Some(true));
1179 assert_eq!(stops[0].operation_name, Some("Operation Blitz".to_string()));
1180 assert_eq!(stops[0].outcome, Some("Arrest".to_string()));
1181 assert_eq!(
1182 stops[0].outcome_object.as_ref().unwrap().name,
1183 Some("Arrest".to_string())
1184 );
1185 }
1186
1187 #[tokio::test]
1188 async fn test_not_found() {
1189 let server = MockServer::start().await;
1190
1191 Mock::given(method("GET"))
1192 .and(path("/forces/nonexistent"))
1193 .respond_with(ResponseTemplate::new(404).set_body_string("Not Found"))
1194 .mount(&server)
1195 .await;
1196
1197 let client = test_client(&server.uri());
1198 let err = client.force("nonexistent").await.unwrap_err();
1199
1200 match err {
1201 Error::Api { status, body } => {
1202 assert_eq!(status, 404);
1203 assert_eq!(body, "Not Found");
1204 }
1205 other => panic!("expected Error::Api, got: {other}"),
1206 }
1207 }
1208
1209 #[tokio::test]
1210 async fn test_rate_limited() {
1211 let server = MockServer::start().await;
1212
1213 Mock::given(method("GET"))
1214 .and(path("/forces"))
1215 .respond_with(ResponseTemplate::new(429).set_body_string("Rate limit exceeded"))
1216 .mount(&server)
1217 .await;
1218
1219 let client = test_client(&server.uri());
1220 let err = client.forces().await.unwrap_err();
1221
1222 match err {
1223 Error::Api { status, body } => {
1224 assert_eq!(status, 429);
1225 assert_eq!(body, "Rate limit exceeded");
1226 }
1227 other => panic!("expected Error::Api, got: {other}"),
1228 }
1229 }
1230
1231 #[tokio::test]
1232 async fn test_bad_request() {
1233 let server = MockServer::start().await;
1234
1235 Mock::given(method("GET"))
1236 .and(path("/crime-categories"))
1237 .respond_with(ResponseTemplate::new(400).set_body_string("Bad Request"))
1238 .mount(&server)
1239 .await;
1240
1241 let client = test_client(&server.uri());
1242 let err = client.crime_categories(None).await.unwrap_err();
1243
1244 match err {
1245 Error::Api { status, body } => {
1246 assert_eq!(status, 400);
1247 assert_eq!(body, "Bad Request");
1248 }
1249 other => panic!("expected Error::Api, got: {other}"),
1250 }
1251 }
1252}