1use crate::client::Client;
15use crate::error::{Error, Result};
16use crate::internal::{apply_pagination, push_opt, push_opt_bool};
17use crate::pagination::{FetchFn, Page, PageStream};
18use crate::resources::agencies::urlencoding;
19use crate::Record;
20use bon::Builder;
21use std::collections::BTreeMap;
22use std::sync::Arc;
23
24#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
41#[non_exhaustive]
42pub struct ListSledOpportunitiesOptions {
43 #[builder(into)]
45 pub page: Option<u32>,
46 #[builder(into)]
48 pub limit: Option<u32>,
49 #[builder(into)]
51 pub cursor: Option<String>,
52 #[builder(into)]
56 pub shape: Option<String>,
57 #[builder(default)]
59 pub flat: bool,
60 #[builder(default)]
62 pub flat_lists: bool,
63
64 #[builder(into)]
66 pub state: Option<String>,
67 #[builder(into)]
70 pub jurisdiction: Option<String>,
71 #[builder(into)]
76 pub status: Option<String>,
77 #[builder(into)]
81 pub active: Option<bool>,
82 #[builder(into)]
86 pub agency: Option<String>,
87 #[builder(into)]
90 pub solicitation_number: Option<String>,
91 #[builder(into)]
95 pub solicitation_type: Option<String>,
96 #[builder(into)]
98 pub has_documents: Option<bool>,
99 #[builder(into)]
103 pub revision_kind: Option<String>,
104
105 #[builder(into)]
110 pub naics: Option<String>,
111 #[builder(into)]
113 pub nigp: Option<String>,
114 #[builder(into)]
116 pub unspsc: Option<String>,
117 #[builder(into)]
120 pub category: Option<String>,
121 #[builder(into)]
125 pub category_code: Option<String>,
126
127 #[builder(into)]
129 pub posted_after: Option<String>,
130 #[builder(into)]
132 pub posted_before: Option<String>,
133 #[builder(into)]
135 pub response_deadline_after: Option<String>,
136 #[builder(into)]
138 pub response_deadline_before: Option<String>,
139 #[builder(into)]
142 pub first_seen_after: Option<String>,
143 #[builder(into)]
145 pub first_seen_before: Option<String>,
146 #[builder(into)]
151 pub change_seen_after: Option<String>,
152 #[builder(into)]
154 pub modified_after: Option<String>,
155 #[builder(into)]
157 pub modified_before: Option<String>,
158
159 #[builder(into)]
162 pub platform: Option<String>,
163 #[builder(into)]
165 pub native_id: Option<String>,
166 #[builder(into)]
168 pub external_id: Option<String>,
169
170 #[builder(into)]
175 pub search: Option<String>,
176 #[builder(into)]
180 pub ordering: Option<String>,
181
182 pub verbose: Option<bool>,
184
185 #[builder(default)]
187 pub extra: BTreeMap<String, String>,
188}
189
190impl ListSledOpportunitiesOptions {
191 pub(crate) fn to_query(&self) -> Vec<(String, String)> {
192 let mut q = Vec::new();
193 apply_pagination(
194 &mut q,
195 self.page,
196 self.limit,
197 self.cursor.as_deref(),
198 self.shape.as_deref(),
199 self.flat,
200 self.flat_lists,
201 );
202 push_opt(&mut q, "state", self.state.as_deref());
203 push_opt(&mut q, "jurisdiction", self.jurisdiction.as_deref());
204 push_opt(&mut q, "status", self.status.as_deref());
205 push_opt_bool(&mut q, "active", self.active);
206 push_opt(&mut q, "agency", self.agency.as_deref());
207 push_opt(
208 &mut q,
209 "solicitation_number",
210 self.solicitation_number.as_deref(),
211 );
212 push_opt(
213 &mut q,
214 "solicitation_type",
215 self.solicitation_type.as_deref(),
216 );
217 push_opt_bool(&mut q, "has_documents", self.has_documents);
218 push_opt(&mut q, "revision_kind", self.revision_kind.as_deref());
219 push_opt(&mut q, "naics", self.naics.as_deref());
220 push_opt(&mut q, "nigp", self.nigp.as_deref());
221 push_opt(&mut q, "unspsc", self.unspsc.as_deref());
222 push_opt(&mut q, "category", self.category.as_deref());
223 push_opt(&mut q, "category_code", self.category_code.as_deref());
224 push_opt(&mut q, "posted_after", self.posted_after.as_deref());
225 push_opt(&mut q, "posted_before", self.posted_before.as_deref());
226 push_opt(
227 &mut q,
228 "response_deadline_after",
229 self.response_deadline_after.as_deref(),
230 );
231 push_opt(
232 &mut q,
233 "response_deadline_before",
234 self.response_deadline_before.as_deref(),
235 );
236 push_opt(&mut q, "first_seen_after", self.first_seen_after.as_deref());
237 push_opt(
238 &mut q,
239 "first_seen_before",
240 self.first_seen_before.as_deref(),
241 );
242 push_opt(
243 &mut q,
244 "change_seen_after",
245 self.change_seen_after.as_deref(),
246 );
247 push_opt(&mut q, "modified_after", self.modified_after.as_deref());
248 push_opt(&mut q, "modified_before", self.modified_before.as_deref());
249 push_opt(&mut q, "platform", self.platform.as_deref());
250 push_opt(&mut q, "native_id", self.native_id.as_deref());
251 push_opt(&mut q, "external_id", self.external_id.as_deref());
252 push_opt(&mut q, "search", self.search.as_deref());
253 push_opt_bool(&mut q, "verbose", self.verbose);
254 push_opt(&mut q, "ordering", self.ordering.as_deref());
255 for (k, v) in &self.extra {
256 if !v.is_empty() {
257 q.push((k.clone(), v.clone()));
258 }
259 }
260 q
261 }
262}
263
264#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
267#[non_exhaustive]
268pub struct ListSledOpportunityRevisionsOptions {
269 #[builder(into)]
271 pub page: Option<u32>,
272 #[builder(into)]
274 pub limit: Option<u32>,
275 #[builder(into)]
277 pub cursor: Option<String>,
278 #[builder(into)]
282 pub shape: Option<String>,
283 #[builder(default)]
285 pub flat: bool,
286 #[builder(default)]
288 pub flat_lists: bool,
289
290 #[builder(into)]
293 pub kind: Option<String>,
294 #[builder(into)]
298 pub source_declared: Option<bool>,
299 #[builder(into)]
301 pub observed_after: Option<String>,
302 #[builder(into)]
304 pub observed_before: Option<String>,
305
306 #[builder(default)]
308 pub extra: BTreeMap<String, String>,
309}
310
311impl ListSledOpportunityRevisionsOptions {
312 pub(crate) fn to_query(&self) -> Vec<(String, String)> {
313 let mut q = Vec::new();
314 apply_pagination(
315 &mut q,
316 self.page,
317 self.limit,
318 self.cursor.as_deref(),
319 self.shape.as_deref(),
320 self.flat,
321 self.flat_lists,
322 );
323 push_opt(&mut q, "kind", self.kind.as_deref());
324 push_opt_bool(&mut q, "source_declared", self.source_declared);
325 push_opt(&mut q, "observed_after", self.observed_after.as_deref());
326 push_opt(&mut q, "observed_before", self.observed_before.as_deref());
327 for (k, v) in &self.extra {
328 if !v.is_empty() {
329 q.push((k.clone(), v.clone()));
330 }
331 }
332 q
333 }
334}
335
336#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
344#[non_exhaustive]
345pub struct ListSledForecastsOptions {
346 #[builder(into)]
348 pub page: Option<u32>,
349 #[builder(into)]
351 pub limit: Option<u32>,
352 #[builder(into)]
354 pub cursor: Option<String>,
355 #[builder(into)]
359 pub shape: Option<String>,
360 #[builder(default)]
362 pub flat: bool,
363 #[builder(default)]
365 pub flat_lists: bool,
366
367 #[builder(into)]
369 pub state: Option<String>,
370 #[builder(into)]
372 pub agency: Option<String>,
373 #[builder(into)]
375 pub procurement_category: Option<String>,
376 #[builder(into)]
378 pub procurement_method: Option<String>,
379 #[builder(into)]
382 pub contract_number: Option<String>,
383 #[builder(into)]
386 pub incumbent_name: Option<String>,
387 #[builder(into)]
390 pub advertisement_after: Option<String>,
391 #[builder(into)]
393 pub advertisement_before: Option<String>,
394 #[builder(into)]
396 pub first_seen_after: Option<String>,
397 #[builder(into)]
399 pub first_seen_before: Option<String>,
400 #[builder(into)]
402 pub modified_after: Option<String>,
403 #[builder(into)]
405 pub modified_before: Option<String>,
406 #[builder(into)]
409 pub search: Option<String>,
410 #[builder(into)]
414 pub ordering: Option<String>,
415
416 #[builder(default)]
418 pub extra: BTreeMap<String, String>,
419}
420
421impl ListSledForecastsOptions {
422 pub(crate) fn to_query(&self) -> Vec<(String, String)> {
423 let mut q = Vec::new();
424 apply_pagination(
425 &mut q,
426 self.page,
427 self.limit,
428 self.cursor.as_deref(),
429 self.shape.as_deref(),
430 self.flat,
431 self.flat_lists,
432 );
433 push_opt(&mut q, "state", self.state.as_deref());
434 push_opt(&mut q, "agency", self.agency.as_deref());
435 push_opt(
436 &mut q,
437 "procurement_category",
438 self.procurement_category.as_deref(),
439 );
440 push_opt(
441 &mut q,
442 "procurement_method",
443 self.procurement_method.as_deref(),
444 );
445 push_opt(&mut q, "contract_number", self.contract_number.as_deref());
446 push_opt(&mut q, "incumbent_name", self.incumbent_name.as_deref());
447 push_opt(
448 &mut q,
449 "advertisement_after",
450 self.advertisement_after.as_deref(),
451 );
452 push_opt(
453 &mut q,
454 "advertisement_before",
455 self.advertisement_before.as_deref(),
456 );
457 push_opt(&mut q, "first_seen_after", self.first_seen_after.as_deref());
458 push_opt(
459 &mut q,
460 "first_seen_before",
461 self.first_seen_before.as_deref(),
462 );
463 push_opt(&mut q, "modified_after", self.modified_after.as_deref());
464 push_opt(&mut q, "modified_before", self.modified_before.as_deref());
465 push_opt(&mut q, "search", self.search.as_deref());
466 push_opt(&mut q, "ordering", self.ordering.as_deref());
467 for (k, v) in &self.extra {
468 if !v.is_empty() {
469 q.push((k.clone(), v.clone()));
470 }
471 }
472 q
473 }
474}
475
476#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
479#[non_exhaustive]
480pub struct GetSledOptions {
481 #[builder(into)]
483 pub shape: Option<String>,
484 #[builder(default)]
486 pub flat: bool,
487 #[builder(default)]
489 pub flat_lists: bool,
490}
491
492impl GetSledOptions {
493 pub(crate) fn to_query(&self) -> Vec<(String, String)> {
494 let mut q = Vec::new();
495 push_opt(&mut q, "shape", self.shape.as_deref());
496 if self.flat {
497 q.push(("flat".into(), "true".into()));
498 }
499 if self.flat_lists {
500 q.push(("flat_lists".into(), "true".into()));
501 }
502 q
503 }
504}
505
506impl Client {
507 pub async fn list_sled_opportunities(
516 &self,
517 opts: ListSledOpportunitiesOptions,
518 ) -> Result<Page<Record>> {
519 let q = opts.to_query();
520 self.get_page("/api/sled/opportunities/", &q).await
521 }
522
523 pub async fn get_sled_opportunity(
539 &self,
540 opportunity_id: &str,
541 opts: Option<GetSledOptions>,
542 ) -> Result<Record> {
543 if opportunity_id.is_empty() {
544 return Err(Error::Validation {
545 message: "get_sled_opportunity: opportunity_id is required".into(),
546 response: None,
547 });
548 }
549 let q = opts.unwrap_or_default().to_query();
550 let path = format!("/api/sled/opportunities/{}/", urlencoding(opportunity_id));
551 self.get_json::<Record>(&path, &q).await
552 }
553
554 pub async fn list_sled_opportunity_revisions(
569 &self,
570 opportunity_id: &str,
571 opts: ListSledOpportunityRevisionsOptions,
572 ) -> Result<Page<Record>> {
573 if opportunity_id.is_empty() {
574 return Err(Error::Validation {
575 message: "list_sled_opportunity_revisions: opportunity_id is required".into(),
576 response: None,
577 });
578 }
579 let q = opts.to_query();
580 let path = format!(
581 "/api/sled/opportunities/{}/revisions/",
582 urlencoding(opportunity_id)
583 );
584 self.get_page(&path, &q).await
585 }
586
587 pub async fn get_sled_coverage(&self) -> Result<Record> {
598 self.get_json::<Record>("/api/sled/opportunities/coverage/", &[])
599 .await
600 }
601
602 pub async fn list_sled_forecasts(
604 &self,
605 opts: ListSledForecastsOptions,
606 ) -> Result<Page<Record>> {
607 let q = opts.to_query();
608 self.get_page("/api/sled/forecasts/", &q).await
609 }
610
611 pub async fn get_sled_forecast(
613 &self,
614 forecast_id: &str,
615 opts: Option<GetSledOptions>,
616 ) -> Result<Record> {
617 if forecast_id.is_empty() {
618 return Err(Error::Validation {
619 message: "get_sled_forecast: forecast_id is required".into(),
620 response: None,
621 });
622 }
623 let q = opts.unwrap_or_default().to_query();
624 let path = format!("/api/sled/forecasts/{}/", urlencoding(forecast_id));
625 self.get_json::<Record>(&path, &q).await
626 }
627
628 pub fn iterate_sled_opportunities(
630 &self,
631 opts: ListSledOpportunitiesOptions,
632 ) -> PageStream<Record> {
633 let opts = Arc::new(opts);
634 let fetch: FetchFn<Record> = Box::new(move |client, page, cursor| {
635 let mut next = (*opts).clone();
636 next.page = page;
637 next.cursor = cursor;
638 Box::pin(async move { client.list_sled_opportunities(next).await })
639 });
640 PageStream::new(self.clone(), fetch)
641 }
642
643 pub fn iterate_sled_forecasts(&self, opts: ListSledForecastsOptions) -> PageStream<Record> {
645 let opts = Arc::new(opts);
646 let fetch: FetchFn<Record> = Box::new(move |client, page, cursor| {
647 let mut next = (*opts).clone();
648 next.page = page;
649 next.cursor = cursor;
650 Box::pin(async move { client.list_sled_forecasts(next).await })
651 });
652 PageStream::new(self.clone(), fetch)
653 }
654}
655
656#[cfg(test)]
657mod tests {
658 use super::*;
659
660 fn get_q(q: &[(String, String)], k: &str) -> Option<String> {
661 q.iter().find(|(kk, _)| kk == k).map(|(_, v)| v.clone())
662 }
663
664 #[test]
665 fn list_sled_opportunities_all_filters_emit() {
666 let opts = ListSledOpportunitiesOptions::builder()
667 .state("TX|OK")
668 .jurisdiction("local|education")
669 .status("open|unknown")
670 .active(true)
671 .agency("Texas Commission")
672 .solicitation_number("RFP-2026-001")
673 .solicitation_type("rfp")
674 .has_documents(true)
675 .revision_kind("deadline_change")
676 .naics("541620")
677 .nigp("962-47")
678 .unspsc("77101500")
679 .category("Environmental Services")
680 .category_code("541620")
681 .posted_after("2026-01-01")
682 .posted_before("2026-12-31")
683 .response_deadline_after("2026-09-01")
684 .response_deadline_before("2026-10-01")
685 .first_seen_after("2026-09-01")
686 .first_seen_before("2026-09-30")
687 .change_seen_after("2026-09-05")
688 .modified_after("2026-09-01")
689 .modified_before("2026-09-30")
690 .platform("custom")
691 .native_id("abc-123")
692 .external_id("tx:custom:abc-123")
693 .search("environmental mitigation")
694 .ordering("response_deadline")
695 .build();
696 let q = opts.to_query();
697 for (key, want) in [
698 ("state", "TX|OK"),
699 ("jurisdiction", "local|education"),
700 ("status", "open|unknown"),
701 ("active", "true"),
702 ("agency", "Texas Commission"),
703 ("solicitation_number", "RFP-2026-001"),
704 ("solicitation_type", "rfp"),
705 ("has_documents", "true"),
706 ("revision_kind", "deadline_change"),
707 ("naics", "541620"),
708 ("nigp", "962-47"),
709 ("unspsc", "77101500"),
710 ("category", "Environmental Services"),
711 ("category_code", "541620"),
712 ("posted_after", "2026-01-01"),
713 ("posted_before", "2026-12-31"),
714 ("response_deadline_after", "2026-09-01"),
715 ("response_deadline_before", "2026-10-01"),
716 ("first_seen_after", "2026-09-01"),
717 ("first_seen_before", "2026-09-30"),
718 ("change_seen_after", "2026-09-05"),
719 ("modified_after", "2026-09-01"),
720 ("modified_before", "2026-09-30"),
721 ("platform", "custom"),
722 ("native_id", "abc-123"),
723 ("external_id", "tx:custom:abc-123"),
724 ("search", "environmental mitigation"),
725 ("ordering", "response_deadline"),
726 ] {
727 assert_eq!(get_q(&q, key).as_deref(), Some(want), "param {key}");
728 }
729 }
730
731 #[test]
734 fn no_liveness_filter_is_sent_when_none_requested() {
735 let opts = ListSledOpportunitiesOptions::builder().state("TX").build();
736 let q = opts.to_query();
737 assert_eq!(get_q(&q, "state").as_deref(), Some("TX"));
738 assert_eq!(get_q(&q, "status"), None);
739 assert_eq!(get_q(&q, "active"), None);
740 }
741
742 #[test]
743 fn active_false_is_sent_not_dropped() {
744 let opts = ListSledOpportunitiesOptions::builder()
745 .active(false)
746 .build();
747 assert_eq!(get_q(&opts.to_query(), "active").as_deref(), Some("false"));
748 }
749
750 #[test]
751 fn has_documents_false_is_sent_not_dropped() {
752 let opts = ListSledOpportunitiesOptions::builder()
753 .has_documents(false)
754 .build();
755 assert_eq!(
756 get_q(&opts.to_query(), "has_documents").as_deref(),
757 Some("false")
758 );
759 }
760
761 #[test]
764 fn source_status_is_not_a_filter() {
765 let opts = ListSledOpportunitiesOptions::builder()
766 .status("closed")
767 .build();
768 let q = opts.to_query();
769 assert_eq!(get_q(&q, "status").as_deref(), Some("closed"));
770 assert_eq!(get_q(&q, "source_status"), None);
771 }
772
773 #[test]
774 fn list_sled_opportunities_zero_value_omitted() {
775 let opts = ListSledOpportunitiesOptions::builder().build();
776 let q = opts.to_query();
777 assert!(q.is_empty(), "expected empty query, got {q:?}");
778 }
779
780 #[test]
781 fn list_sled_opportunities_extra_emits() {
782 let mut extra = BTreeMap::new();
783 extra.insert("verbose".to_string(), "true".to_string());
784 let opts = ListSledOpportunitiesOptions::builder().extra(extra).build();
785 assert!(opts.to_query().contains(&("verbose".into(), "true".into())));
786 }
787
788 #[test]
789 fn revision_filters_emit() {
790 let opts = ListSledOpportunityRevisionsOptions::builder()
791 .kind("deadline_change")
792 .source_declared(true)
793 .observed_after("2026-09-01")
794 .observed_before("2026-09-30")
795 .build();
796 let q = opts.to_query();
797 assert_eq!(get_q(&q, "kind").as_deref(), Some("deadline_change"));
798 assert_eq!(get_q(&q, "source_declared").as_deref(), Some("true"));
799 assert_eq!(get_q(&q, "observed_after").as_deref(), Some("2026-09-01"));
800 assert_eq!(get_q(&q, "observed_before").as_deref(), Some("2026-09-30"));
801 }
802
803 #[test]
806 fn revision_kind_reaches_enrichment() {
807 let opts = ListSledOpportunityRevisionsOptions::builder()
808 .kind("enrichment")
809 .build();
810 assert_eq!(
811 get_q(&opts.to_query(), "kind").as_deref(),
812 Some("enrichment")
813 );
814 }
815
816 #[test]
819 fn revisions_shape_omits_plan_gated_changes() {
820 let shape = crate::SHAPE_SLED_REVISIONS_MINIMAL;
821 assert!(
822 !shape.split(',').any(|f| f == "changes"),
823 "SHAPE_SLED_REVISIONS_MINIMAL must not name the Small-gated `changes` leaf: {shape}"
824 );
825 assert!(
826 shape.contains("changed_fields"),
827 "SHAPE_SLED_REVISIONS_MINIMAL should carry `changed_fields`, which every plan can read: {shape}"
828 );
829 }
830
831 #[test]
834 fn suggested_shapes_do_not_name_the_paid_document_body() {
835 for (name, shape) in [
836 (
837 "SHAPE_SLED_OPPORTUNITIES_MINIMAL",
838 crate::SHAPE_SLED_OPPORTUNITIES_MINIMAL,
839 ),
840 (
841 "SHAPE_SLED_OPPORTUNITIES_COMPREHENSIVE",
842 crate::SHAPE_SLED_OPPORTUNITIES_COMPREHENSIVE,
843 ),
844 ] {
845 assert!(
846 !shape.contains("extracted_text"),
847 "{name} must not name the Small-gated `extracted_text` leaf: {shape}"
848 );
849 }
850 }
851
852 #[test]
854 fn suggested_opportunity_shapes_carry_delisted_at() {
855 for (name, shape) in [
856 (
857 "SHAPE_SLED_OPPORTUNITIES_MINIMAL",
858 crate::SHAPE_SLED_OPPORTUNITIES_MINIMAL,
859 ),
860 (
861 "SHAPE_SLED_OPPORTUNITIES_COMPREHENSIVE",
862 crate::SHAPE_SLED_OPPORTUNITIES_COMPREHENSIVE,
863 ),
864 ] {
865 assert!(
866 shape.split(',').any(|f| f == "delisted_at"),
867 "{name} should carry `delisted_at`: {shape}"
868 );
869 }
870 }
871
872 #[test]
873 fn list_sled_forecasts_filters_emit() {
874 let opts = ListSledForecastsOptions::builder()
875 .state("MD")
876 .agency("Department of Transportation")
877 .procurement_category("Services")
878 .procurement_method("Competitive Sealed Proposals")
879 .contract_number("K-2026-001")
880 .incumbent_name("Acme")
881 .advertisement_after("2026-10-01")
882 .advertisement_before("2027-03-31")
883 .first_seen_after("2026-09-01")
884 .first_seen_before("2026-09-30")
885 .modified_after("2026-09-01")
886 .modified_before("2026-09-30")
887 .search("data center")
888 .ordering("estimated_advertisement_date")
889 .build();
890 let q = opts.to_query();
891 assert_eq!(get_q(&q, "state").as_deref(), Some("MD"));
892 assert_eq!(
893 get_q(&q, "procurement_method").as_deref(),
894 Some("Competitive Sealed Proposals")
895 );
896 assert_eq!(
897 get_q(&q, "advertisement_after").as_deref(),
898 Some("2026-10-01")
899 );
900 assert_eq!(get_q(&q, "search").as_deref(), Some("data center"));
901 assert_eq!(get_q(&q, "status"), None);
903 assert_eq!(get_q(&q, "active"), None);
904 }
905
906 #[test]
907 fn get_sled_options_emit() {
908 let opts = GetSledOptions::builder()
909 .shape(crate::SHAPE_SLED_OPPORTUNITIES_COMPREHENSIVE)
910 .flat(true)
911 .flat_lists(true)
912 .build();
913 let q = opts.to_query();
914 assert_eq!(
915 get_q(&q, "shape").as_deref(),
916 Some(crate::SHAPE_SLED_OPPORTUNITIES_COMPREHENSIVE)
917 );
918 assert_eq!(get_q(&q, "flat").as_deref(), Some("true"));
919 assert_eq!(get_q(&q, "flat_lists").as_deref(), Some("true"));
920 }
921
922 #[test]
923 fn cursor_wins_over_page() {
924 let opts = ListSledOpportunitiesOptions::builder()
925 .page(2u32)
926 .cursor("xyz".to_string())
927 .build();
928 let q = opts.to_query();
929 assert_eq!(get_q(&q, "cursor").as_deref(), Some("xyz"));
930 assert_eq!(get_q(&q, "page"), None);
931 }
932
933 #[tokio::test]
934 async fn get_sled_opportunity_validates_empty_id() {
935 let client = Client::builder().api_key("x").build().expect("client");
936 let err = client.get_sled_opportunity("", None).await.unwrap_err();
937 match err {
938 Error::Validation { message, .. } => {
939 assert!(message.contains("opportunity_id is required"));
940 }
941 other => panic!("expected Validation, got {other:?}"),
942 }
943 }
944
945 #[tokio::test]
946 async fn list_sled_opportunity_revisions_validates_empty_id() {
947 let client = Client::builder().api_key("x").build().expect("client");
948 let err = client
949 .list_sled_opportunity_revisions("", ListSledOpportunityRevisionsOptions::default())
950 .await
951 .unwrap_err();
952 match err {
953 Error::Validation { message, .. } => {
954 assert!(message.contains("opportunity_id is required"));
955 }
956 other => panic!("expected Validation, got {other:?}"),
957 }
958 }
959
960 #[tokio::test]
961 async fn get_sled_forecast_validates_empty_id() {
962 let client = Client::builder().api_key("x").build().expect("client");
963 let err = client.get_sled_forecast("", None).await.unwrap_err();
964 match err {
965 Error::Validation { message, .. } => {
966 assert!(message.contains("forecast_id is required"));
967 }
968 other => panic!("expected Validation, got {other:?}"),
969 }
970 }
971
972 #[test]
973 fn list_sled_opportunities_verbose_emits() {
974 let q = ListSledOpportunitiesOptions::builder()
975 .verbose(true)
976 .build()
977 .to_query();
978 assert_eq!(get_q(&q, "verbose").as_deref(), Some("true"));
979 let q = ListSledOpportunitiesOptions::builder().build().to_query();
980 assert_eq!(get_q(&q, "verbose"), None);
981 }
982}