1use crate::axum::partitions::PartitionsLayer;
2use crate::lnurl::pay::handler::LnUrlPayHandlers;
3use crate::lnurl::pay::state::LnUrlPayState;
4use axum::http::StatusCode;
5use axum::routing::get;
6use axum::Router;
7use std::sync::Arc;
8use switchgear_service_api::balance::LnBalancer;
9use switchgear_service_api::offer::OfferProvider;
10
11#[derive(Debug)]
12pub struct LnUrlBalancerService;
13
14impl LnUrlBalancerService {
15 pub fn router<O, B>(state: LnUrlPayState<O, B>) -> Router
16 where
17 O: OfferProvider + Send + Sync + Clone + 'static,
18 B: LnBalancer + Send + Sync + Clone + 'static,
19 {
20 Router::new()
21 .route(
22 "/offers/{partition}/{id}/bech32/qr",
23 get(LnUrlPayHandlers::bech32_qr),
24 )
25 .route(
26 "/offers/{partition}/{id}/bech32",
27 get(LnUrlPayHandlers::bech32),
28 )
29 .route(
30 "/offers/{partition}/{id}/invoice",
31 get(LnUrlPayHandlers::invoice),
32 )
33 .route("/offers/{partition}/{id}", get(LnUrlPayHandlers::offer))
34 .layer(PartitionsLayer::new(Arc::new(state.partitions().clone())))
35 .route("/health/full", get(LnUrlPayHandlers::health_full))
36 .route("/health", get(Self::health_check_handler))
37 .with_state(state)
38 }
39
40 async fn health_check_handler() -> StatusCode {
41 StatusCode::OK
42 }
43}
44
45#[cfg(test)]
46mod tests {
47 use crate::axum::extract::scheme::Scheme;
48 use crate::lnurl::pay::state::LnUrlPayState;
49 use crate::lnurl::service::LnUrlBalancerService;
50 use crate::testing::offer::store::TestOfferStore;
51 use async_trait::async_trait;
52 use axum::http::StatusCode;
53 use axum_test::TestServer;
54 use chrono::{Duration, Utc};
55 use std::collections::HashSet;
56 use switchgear_service_api::balance::LnBalancer;
57 use switchgear_service_api::lnurl::{LnUrlInvoice, LnUrlOffer, LnUrlOfferMetadata};
58 use switchgear_service_api::offer::{
59 Offer, OfferMetadata, OfferMetadataSparse, OfferMetadataStore, OfferRecord,
60 OfferRecordSparse, OfferStore,
61 };
62 use switchgear_service_api::service::HasServiceErrorSource;
63 use uuid::Uuid;
64
65 #[derive(Debug, Clone)]
67 pub struct MockLnBalancer {
68 should_fail: bool,
69 should_fail_upstream: bool,
70 invoice_response: String,
71 captured_expiry: std::sync::Arc<std::sync::Mutex<Option<u64>>>,
72 }
73
74 impl MockLnBalancer {
75 pub fn new() -> Self {
76 Self {
77 should_fail: false,
78 should_fail_upstream: false,
79 invoice_response: "lnbc1000n1pjdkqs0pp5...".to_string(),
80 captured_expiry: std::sync::Arc::new(std::sync::Mutex::new(None)),
81 }
82 }
83
84 pub fn with_failure() -> Self {
85 Self {
86 should_fail: true,
87 should_fail_upstream: false,
88 invoice_response: String::new(),
89 captured_expiry: std::sync::Arc::new(std::sync::Mutex::new(None)),
90 }
91 }
92
93 pub fn with_invoice(invoice: &str) -> Self {
94 Self {
95 should_fail: false,
96 should_fail_upstream: false,
97 invoice_response: invoice.to_string(),
98 captured_expiry: std::sync::Arc::new(std::sync::Mutex::new(None)),
99 }
100 }
101
102 pub fn captured_expiry(&self) -> Option<u64> {
103 *self.captured_expiry.lock().unwrap()
104 }
105 }
106
107 #[derive(Debug, thiserror::Error)]
108 pub enum MockLnBalancerCombinedError {
109 #[error("Mock LnBalancer internal error")]
110 Internal,
111 #[error("Mock LnBalancer upstream error")]
112 Upstream,
113 }
114
115 impl HasServiceErrorSource for MockLnBalancerCombinedError {
116 fn get_service_error_source(&self) -> switchgear_service_api::service::ServiceErrorSource {
117 match self {
118 MockLnBalancerCombinedError::Internal => {
119 switchgear_service_api::service::ServiceErrorSource::Internal
120 }
121 MockLnBalancerCombinedError::Upstream => {
122 switchgear_service_api::service::ServiceErrorSource::Upstream
123 }
124 }
125 }
126 }
127
128 #[async_trait]
129 impl LnBalancer for MockLnBalancer {
130 type Error = MockLnBalancerCombinedError;
131
132 async fn get_invoice(
133 &self,
134 _offer: &Offer,
135 _amount_msat: u64,
136 expiry_secs: u64,
137 _key: &[u8],
138 ) -> Result<String, Self::Error> {
139 *self.captured_expiry.lock().unwrap() = Some(expiry_secs);
141
142 if self.should_fail_upstream {
143 Err(MockLnBalancerCombinedError::Upstream)
144 } else if self.should_fail {
145 Err(MockLnBalancerCombinedError::Internal)
146 } else {
147 Ok(self.invoice_response.clone())
148 }
149 }
150
151 async fn health(&self) -> Result<(), Self::Error> {
152 Ok(())
153 }
154 }
155
156 fn create_test_offer_and_metadata() -> (OfferRecord, OfferMetadata) {
158 let metadata_id = Uuid::new_v4();
160 let metadata = OfferMetadata {
161 id: metadata_id,
162 partition: "default".to_string(),
163 metadata: OfferMetadataSparse {
164 text: "Test offer".to_string(),
165 long_text: Some("This is a test offer for LNURL Pay".to_string()),
166 image: None,
167 identifier: None,
168 },
169 };
170
171 let offer = OfferRecord {
172 partition: "default".to_string(),
173 id: Uuid::new_v4(),
174 offer: OfferRecordSparse {
175 max_sendable: 1000000,
176 min_sendable: 1000,
177 metadata_id,
178 metadata: None,
179 timestamp: Utc::now() - Duration::hours(1),
180 expires: Some(Utc::now() + Duration::hours(1)),
181 },
182 };
183
184 (offer, metadata)
185 }
186
187 fn create_test_offer() -> OfferRecord {
188 let (offer, _) = create_test_offer_and_metadata();
189 offer
190 }
191
192 async fn create_test_server_with_offer(offer: OfferRecord) -> TestServer {
193 create_test_server_with_offer_and_expiry(offer, 3600).await
194 }
195
196 async fn create_test_server_with_offer_and_expiry(
197 offer: OfferRecord,
198 expiry: u64,
199 ) -> TestServer {
200 let (server, _) =
201 create_test_server_with_offer_and_expiry_and_balancer(offer, expiry).await;
202 server
203 }
204
205 async fn create_test_server_with_offer_and_expiry_and_balancer(
206 offer: OfferRecord,
207 expiry: u64,
208 ) -> (TestServer, MockLnBalancer) {
209 create_test_server_with_offer_and_expiry_and_balancer_and_partitions(offer, expiry, None)
210 .await
211 }
212
213 async fn create_test_server_with_offer_and_expiry_and_balancer_and_partitions(
214 offer: OfferRecord,
215 expiry: u64,
216 partitions: Option<HashSet<String>>,
217 ) -> (TestServer, MockLnBalancer) {
218 let partition = offer.partition.clone();
219 let offer_provider = TestOfferStore::default();
220
221 let metadata = OfferMetadata {
223 id: offer.offer.metadata_id,
224 partition: offer.partition.clone(),
225 metadata: OfferMetadataSparse {
226 text: "Test offer".to_string(),
227 long_text: Some("This is a test offer for LNURL Pay".to_string()),
228 image: None,
229 identifier: None,
230 },
231 };
232 offer_provider.put_metadata(metadata).await.unwrap();
233 offer_provider.put_offer(offer).await.unwrap();
234
235 let balancer = MockLnBalancer::new();
236 let partitions = partitions.unwrap_or_else(|| HashSet::from([partition.clone()]));
237 let state = LnUrlPayState::new(
238 partitions,
239 offer_provider,
240 balancer.clone(),
241 expiry,
242 Scheme("http".to_string()),
243 Default::default(),
244 Default::default(),
245 8,
246 255u8,
247 0u8,
248 );
249
250 let app = LnUrlBalancerService::router(state);
251 let server = TestServer::new(app).unwrap();
252 (server, balancer)
253 }
254
255 fn create_empty_test_server() -> TestServer {
256 let offer_provider = TestOfferStore::default();
257 let balancer = MockLnBalancer::new();
258 let state = LnUrlPayState::new(
259 HashSet::from(["default".to_string()]),
260 offer_provider,
261 balancer,
262 3600,
263 Scheme("http".to_string()),
264 Default::default(),
265 Default::default(),
266 8,
267 255u8,
268 0u8,
269 );
270
271 let app = LnUrlBalancerService::router(state);
272 TestServer::new(app).unwrap()
273 }
274
275 async fn create_test_server_with_failing_balancer(offer: OfferRecord) -> TestServer {
276 let partition = offer.partition.clone();
277 let offer_provider = TestOfferStore::default();
278
279 let metadata = OfferMetadata {
281 id: offer.offer.metadata_id,
282 partition: offer.partition.clone(),
283 metadata: OfferMetadataSparse {
284 text: "Test offer".to_string(),
285 long_text: Some("This is a test offer for LNURL Pay".to_string()),
286 image: None,
287 identifier: None,
288 },
289 };
290 offer_provider.put_metadata(metadata).await.unwrap();
291 offer_provider.put_offer(offer).await.unwrap();
292
293 let balancer = MockLnBalancer::with_failure();
294 let state = LnUrlPayState::new(
295 HashSet::from([partition.clone()]),
296 offer_provider,
297 balancer,
298 3600,
299 Scheme("http".to_string()),
300 Default::default(),
301 Default::default(),
302 8,
303 255u8,
304 0u8,
305 );
306
307 let app = LnUrlBalancerService::router(state);
308 TestServer::new(app).unwrap()
309 }
310
311 #[tokio::test]
314 async fn health_check_when_called_then_returns_ok() {
315 let server = create_empty_test_server();
316 let response = server.get("/health").await;
317
318 assert_eq!(response.status_code(), StatusCode::OK);
319 assert_eq!(response.text(), "");
320 }
321
322 #[tokio::test]
325 async fn get_offer_when_exists_then_returns_lnurl_pay_request() {
326 let test_offer = create_test_offer();
327 let offer_id = test_offer.id;
328 let server = create_test_server_with_offer(test_offer.clone()).await;
329
330 let response = server.get(&format!("/offers/default/{offer_id}")).await;
331
332 assert_eq!(response.status_code(), StatusCode::OK);
333
334 let offer: LnUrlOffer = response.json();
336 assert!(
337 offer.callback.host_str().unwrap() == "127.0.0.1"
338 || offer.callback.host_str().unwrap() == "localhost"
339 );
340 assert!(offer
341 .callback
342 .path()
343 .contains(&format!("/offers/default/{offer_id}/invoice")));
344 assert_eq!(offer.max_sendable, test_offer.offer.max_sendable);
345 assert_eq!(offer.min_sendable, test_offer.offer.min_sendable);
346
347 let metadata: LnUrlOfferMetadata = serde_json::from_str(&offer.metadata).unwrap();
349 assert_eq!(metadata.0.text, "Test offer");
350 assert_eq!(
351 metadata.0.long_text,
352 Some("This is a test offer for LNURL Pay".to_string())
353 );
354 }
355
356 async fn create_test_server_with_scheme(
357 offer: OfferRecord,
358 scheme: &str,
359 ) -> (TestServer, Uuid) {
360 let partition = offer.partition.clone();
361 let offer_provider = TestOfferStore::default();
362
363 let metadata = OfferMetadata {
364 id: offer.offer.metadata_id,
365 partition: offer.partition.clone(),
366 metadata: OfferMetadataSparse {
367 text: "Test offer".to_string(),
368 long_text: Some("This is a test offer for LNURL Pay".to_string()),
369 image: None,
370 identifier: None,
371 },
372 };
373 offer_provider.put_metadata(metadata).await.unwrap();
374 offer_provider.put_offer(offer.clone()).await.unwrap();
375
376 let balancer = MockLnBalancer::new();
377 let state = LnUrlPayState::new(
378 HashSet::from([partition.clone()]),
379 offer_provider,
380 balancer,
381 3600,
382 Scheme(scheme.to_string()),
383 Default::default(),
384 Default::default(),
385 8,
386 255u8,
387 0u8,
388 );
389
390 let app = LnUrlBalancerService::router(state);
391 let server = TestServer::new(app).unwrap();
392 (server, offer.id)
393 }
394
395 #[tokio::test]
396 async fn get_offer_callback_uses_default_scheme() {
397 let test_offer = create_test_offer();
398 let (server, offer_id) = create_test_server_with_scheme(test_offer, "https").await;
399
400 let response = server.get(&format!("/offers/default/{offer_id}")).await;
401 assert_eq!(response.status_code(), StatusCode::OK);
402
403 let offer: LnUrlOffer = response.json();
404 assert_eq!(offer.callback.scheme(), "https");
405 }
406
407 #[tokio::test]
408 async fn get_offer_callback_respects_x_forwarded_proto_header() {
409 let test_offer = create_test_offer();
410 let (server, offer_id) = create_test_server_with_scheme(test_offer, "http").await;
411
412 let response = server
413 .get(&format!("/offers/default/{offer_id}"))
414 .add_header("X-Forwarded-Proto", "https")
415 .await;
416 assert_eq!(response.status_code(), StatusCode::OK);
417
418 let offer: LnUrlOffer = response.json();
419 assert_eq!(offer.callback.scheme(), "https");
420 }
421
422 #[tokio::test]
423 async fn get_offer_callback_respects_forwarded_header() {
424 let test_offer = create_test_offer();
425 let (server, offer_id) = create_test_server_with_scheme(test_offer, "http").await;
426
427 let response = server
428 .get(&format!("/offers/default/{offer_id}"))
429 .add_header("Forwarded", "proto=wss;host=example.com")
430 .await;
431 assert_eq!(response.status_code(), StatusCode::OK);
432
433 let offer: LnUrlOffer = response.json();
434 assert_eq!(offer.callback.scheme(), "wss");
435 }
436
437 #[tokio::test]
438 async fn get_offer_callback_forwarded_header_takes_precedence() {
439 let test_offer = create_test_offer();
440 let (server, offer_id) = create_test_server_with_scheme(test_offer, "http").await;
441
442 let response = server
443 .get(&format!("/offers/default/{offer_id}"))
444 .add_header("Forwarded", "proto=wss")
445 .add_header("X-Forwarded-Proto", "https")
446 .await;
447 assert_eq!(response.status_code(), StatusCode::OK);
448
449 let offer: LnUrlOffer = response.json();
450 assert_eq!(offer.callback.scheme(), "wss");
451 }
452
453 #[tokio::test]
454 async fn get_offer_cache_headers_when_expires_in_30_minutes() {
455 let mut test_offer = create_test_offer();
456 test_offer.offer.expires = Some(Utc::now() + Duration::minutes(30));
458 let offer_id = test_offer.id;
459 let server = create_test_server_with_offer(test_offer).await;
460
461 let response = server.get(&format!("/offers/default/{offer_id}")).await;
462
463 assert_eq!(response.status_code(), StatusCode::OK);
464
465 let cache_control = response.header("cache-control");
467 let cache_control_str = cache_control.to_str().unwrap();
468 assert!(cache_control_str.starts_with("public, max-age="));
469 let max_age: u64 = cache_control_str
470 .strip_prefix("public, max-age=")
471 .unwrap()
472 .parse()
473 .unwrap();
474 assert!((1799..=1800).contains(&max_age));
476
477 let expires_header = response.header("expires");
479 let expires_header_str = expires_header.to_str().unwrap();
480 assert!(!expires_header_str.is_empty());
481 assert!(expires_header_str.ends_with(" GMT"));
482 }
483
484 #[tokio::test]
485 async fn get_offer_cache_headers_when_expires_in_5_minutes() {
486 let mut test_offer = create_test_offer();
487 test_offer.offer.expires = Some(Utc::now() + Duration::minutes(5));
489 let offer_id = test_offer.id;
490 let server = create_test_server_with_offer(test_offer).await;
491
492 let response = server.get(&format!("/offers/default/{offer_id}")).await;
493
494 assert_eq!(response.status_code(), StatusCode::OK);
495
496 let cache_control = response.header("cache-control");
498 let cache_control_str = cache_control.to_str().unwrap();
499 assert!(cache_control_str.starts_with("public, max-age="));
500 let max_age: u64 = cache_control_str
501 .strip_prefix("public, max-age=")
502 .unwrap()
503 .parse()
504 .unwrap();
505 assert!((299..=300).contains(&max_age));
507
508 let expires_header = response.header("expires");
510 let expires_header_str = expires_header.to_str().unwrap();
511 assert!(!expires_header_str.is_empty());
512 assert!(expires_header_str.ends_with(" GMT"));
513 }
514
515 #[tokio::test]
516 async fn get_offer_no_cache_headers_when_expires_is_none() {
517 let mut test_offer = create_test_offer();
518 test_offer.offer.expires = None;
520 let offer_id = test_offer.id;
521 let server = create_test_server_with_offer(test_offer).await;
522
523 let response = server.get(&format!("/offers/default/{offer_id}")).await;
524 assert_eq!(response.status_code(), StatusCode::OK);
525
526 let cache_control = response.header("cache-control");
529 let cache_control_str = cache_control.to_str().unwrap();
530 assert_eq!(cache_control_str, "no-store, no-cache, must-revalidate");
531
532 let expires_header = response.header("expires");
534 let expires_header_str = expires_header.to_str().unwrap();
535 assert_eq!(expires_header_str, "Thu, 01 Jan 1970 00:00:00 GMT");
536
537 let pragma_header = response.header("pragma");
539 let pragma_header_str = pragma_header.to_str().unwrap();
540 assert_eq!(pragma_header_str, "no-cache");
541 }
542
543 #[tokio::test]
544 async fn get_offer_when_not_exists_then_returns_not_found() {
545 let server = create_empty_test_server();
546 let non_existent_id = Uuid::new_v4();
547
548 let response = server
549 .get(&format!("/offers/default/{non_existent_id}"))
550 .await;
551
552 assert_eq!(response.status_code(), StatusCode::NOT_FOUND);
553 }
554
555 #[tokio::test]
556 async fn get_offer_when_expired_then_returns_gone() {
557 let mut test_offer = create_test_offer();
558 test_offer.offer.expires = Some(Utc::now() - Duration::hours(1));
560 let offer_id = test_offer.id;
561 let server = create_test_server_with_offer(test_offer).await;
562
563 let response = server.get(&format!("/offers/default/{offer_id}")).await;
564
565 assert_eq!(response.status_code(), StatusCode::NOT_FOUND);
566 }
567
568 #[tokio::test]
569 async fn get_offer_when_invalid_uuid_then_returns_not_found() {
570 let server = create_empty_test_server();
571
572 let response = server.get("/offers/default/invalid-uuid").await;
573
574 assert_eq!(response.status_code(), StatusCode::NOT_FOUND);
575 }
576
577 #[tokio::test]
580 async fn get_invoice_when_valid_request_then_returns_invoice() {
581 let test_offer = create_test_offer();
582 let offer_id = test_offer.id;
583 let server = create_test_server_with_offer(test_offer).await;
584
585 let response = server
586 .get(&format!("/offers/default/{offer_id}/invoice?amount=500000",))
587 .await;
588
589 assert_eq!(response.status_code(), StatusCode::OK);
590
591 let invoice: LnUrlInvoice = response.json();
593 assert!(invoice.pr.starts_with("lnbc"));
594 assert_eq!(invoice.routes.len(), 0);
595 }
596
597 #[tokio::test]
598 async fn get_invoice_when_offer_not_exists_then_returns_not_found() {
599 let server = create_empty_test_server();
600 let non_existent_id = Uuid::new_v4();
601
602 let response = server
603 .get(&format!(
604 "/offers/default/{non_existent_id}/invoice?amount=500000",
605 ))
606 .await;
607
608 assert_eq!(response.status_code(), StatusCode::NOT_FOUND);
609 }
610
611 #[tokio::test]
612 async fn get_invoice_when_amount_missing_then_returns_bad_request() {
613 let test_offer = create_test_offer();
614 let offer_id = test_offer.id;
615 let server = create_test_server_with_offer(test_offer).await;
616
617 let response = server
618 .get(&format!("/offers/default/{offer_id}/invoice"))
619 .await;
620
621 assert_eq!(response.status_code(), StatusCode::BAD_REQUEST);
623 }
624
625 #[tokio::test]
626 async fn get_invoice_when_amount_valid_then_passes_to_balancer() {
627 let test_offer = create_test_offer();
628 let offer_id = test_offer.id;
629 let server = create_test_server_with_offer(test_offer.clone()).await;
630
631 let response = server
633 .get(&format!(
634 "/offers/default/{}/invoice?amount={}",
635 offer_id, test_offer.offer.min_sendable
636 ))
637 .await;
638
639 assert_eq!(response.status_code(), StatusCode::OK);
640
641 let invoice: LnUrlInvoice = response.json();
642 assert!(invoice.pr.starts_with("lnbc"));
643 assert_eq!(invoice.routes.len(), 0);
644 }
645
646 #[tokio::test]
647 async fn get_invoice_when_amount_outside_range_then_returns_bad_request() {
648 let test_offer = create_test_offer();
649 let offer_id = test_offer.id;
650 let server = create_test_server_with_offer(test_offer.clone()).await;
651
652 let response = server
654 .get(&format!(
655 "/offers/default/{}/invoice?amount={}",
656 offer_id,
657 test_offer.offer.max_sendable + 1
658 ))
659 .await;
660
661 assert_eq!(response.status_code(), StatusCode::BAD_REQUEST);
662
663 let response = server
665 .get(&format!(
666 "/offers/default/{}/invoice?amount={}",
667 offer_id,
668 test_offer.offer.min_sendable - 1
669 ))
670 .await;
671
672 assert_eq!(response.status_code(), StatusCode::BAD_REQUEST);
673 }
674
675 #[tokio::test]
676 async fn get_invoice_when_invalid_amount_then_returns_bad_request() {
677 let test_offer = create_test_offer();
678 let offer_id = test_offer.id;
679 let server = create_test_server_with_offer(test_offer).await;
680
681 let response = server
682 .get(&format!(
683 "/offers/default/{offer_id}/invoice?amount=invalid",
684 ))
685 .await;
686
687 assert_eq!(response.status_code(), StatusCode::BAD_REQUEST);
688 }
689
690 #[tokio::test]
691 async fn get_invoice_when_expired_offer_then_returns_not_found() {
692 let mut test_offer = create_test_offer();
693 test_offer.offer.expires = Some(Utc::now() - Duration::hours(1));
695 let offer_id = test_offer.id;
696 let server = create_test_server_with_offer(test_offer).await;
697
698 let response = server
699 .get(&format!("/offers/default/{offer_id}/invoice?amount=500000",))
700 .await;
701
702 assert_eq!(response.status_code(), StatusCode::NOT_FOUND);
703 }
704
705 #[tokio::test]
706 async fn get_invoice_when_balancer_fails_then_returns_internal_server_error() {
707 let test_offer = create_test_offer();
708 let offer_id = test_offer.id;
709 let server = create_test_server_with_failing_balancer(test_offer).await;
710
711 let response = server
712 .get(&format!("/offers/default/{offer_id}/invoice?amount=500000",))
713 .await;
714
715 assert_eq!(response.status_code(), StatusCode::INTERNAL_SERVER_ERROR);
716 }
717
718 #[tokio::test]
719 async fn get_invoice_when_invalid_uuid_then_returns_not_found() {
720 let server = create_empty_test_server();
721
722 let response = server
723 .get("/offers/default/invalid-uuid/invoice?amount=500000")
724 .await;
725
726 assert_eq!(response.status_code(), StatusCode::NOT_FOUND);
727 }
728
729 #[tokio::test]
732 async fn get_invoice_with_custom_invoice_response() {
733 let test_offer = create_test_offer();
734 let offer_provider = TestOfferStore::default();
735
736 let partition = test_offer.partition.clone();
737
738 let metadata = OfferMetadata {
740 id: test_offer.offer.metadata_id,
741 partition: test_offer.partition.clone(),
742 metadata: OfferMetadataSparse {
743 text: "Test offer".to_string(),
744 long_text: Some("This is a test offer for LNURL Pay".to_string()),
745 image: None,
746 identifier: None,
747 },
748 };
749 offer_provider.put_metadata(metadata).await.unwrap();
750 offer_provider.put_offer(test_offer.clone()).await.unwrap();
751
752 let custom_invoice = "lnbc5000n1pjdkqs0pp5custom...";
753 let balancer = MockLnBalancer::with_invoice(custom_invoice);
754 let state = LnUrlPayState::new(
755 HashSet::from([partition]),
756 offer_provider,
757 balancer,
758 3600,
759 Scheme("http".to_string()),
760 Default::default(),
761 Default::default(),
762 8,
763 255u8,
764 0u8,
765 );
766 let app = LnUrlBalancerService::router(state);
767 let server = TestServer::new(app).unwrap();
768
769 let offer_id = test_offer.id;
770
771 let response = server
772 .get(&format!("/offers/default/{offer_id}/invoice?amount=500000",))
773 .await;
774
775 assert_eq!(response.status_code(), StatusCode::OK);
776
777 let invoice: LnUrlInvoice = response.json();
778 assert_eq!(invoice.pr, custom_invoice);
779 }
780
781 #[tokio::test]
782 async fn get_invoice_when_valid_request_then_uses_configured_expiry() {
783 let test_offer = create_test_offer();
784 let offer_id = test_offer.id;
785 let expected_expiry = 7200u64; let (server, balancer) =
787 create_test_server_with_offer_and_expiry_and_balancer(test_offer, expected_expiry)
788 .await;
789
790 let response = server
791 .get(&format!("/offers/default/{offer_id}/invoice?amount=500000",))
792 .await;
793
794 assert_eq!(response.status_code(), StatusCode::OK);
795
796 assert_eq!(balancer.captured_expiry(), Some(expected_expiry));
798 }
799
800 #[tokio::test]
803 async fn get_bech32_when_valid_offer_then_returns_bech32_string() {
804 let test_offer = create_test_offer();
805 let offer_id = test_offer.id;
806 let server = create_test_server_with_offer(test_offer).await;
807
808 let request_url = format!("/offers/default/{offer_id}");
809 let request_url_bech32 = format!("{request_url}/bech32");
810
811 let response = server.get(&request_url_bech32).await;
812
813 assert_eq!(response.status_code(), StatusCode::OK);
814 assert_eq!(
815 response.header("content-type").to_str().unwrap(),
816 "text/plain; charset=utf-8"
817 );
818
819 let bech32_str = response.text();
820 let (hrp, data) = bech32::decode(&bech32_str).unwrap();
821 assert_eq!(hrp.to_string().to_uppercase(), "LNURL");
822
823 let decoded_bytes: Vec<u8> = data.into_iter().collect();
824 let decoded_url = String::from_utf8(decoded_bytes).unwrap();
825 assert_eq!(format!("http://localhost{request_url}"), decoded_url);
826 }
827
828 #[tokio::test]
829 async fn get_bech32_qr_when_valid_offer_then_returns_png_image() {
830 let test_offer = create_test_offer();
831 let offer_id = test_offer.id;
832 let server = create_test_server_with_offer(test_offer).await;
833
834 let request_url = format!("/offers/default/{offer_id}");
835 let request_url_bech32_qr = format!("{request_url}/bech32/qr");
836
837 let response = server.get(&request_url_bech32_qr).await;
838
839 assert_eq!(response.status_code(), StatusCode::OK);
840 assert_eq!(
841 response.header("content-type").to_str().unwrap(),
842 "image/png"
843 );
844
845 let png_bytes = response.as_bytes();
846
847 use png::Decoder;
849 use std::io::Cursor;
850
851 let decoder = Decoder::new(Cursor::new(&png_bytes));
852 let mut reader = decoder.read_info().unwrap();
853 let mut buf = vec![
854 0;
855 reader
856 .output_buffer_size()
857 .expect("PNG has no output buffer size")
858 ];
859 let info = reader.next_frame(&mut buf).unwrap();
860 let bytes = &buf[..info.buffer_size()];
861
862 use rqrr::PreparedImage;
864 let img = image::GrayImage::from_raw(info.width, info.height, bytes.to_vec())
865 .expect("Failed to create image from PNG data");
866
867 let mut prepared = PreparedImage::prepare(img);
868 let grids = prepared.detect_grids();
869 assert!(!grids.is_empty(), "Should detect at least one QR code");
870
871 let (_, content) = grids[0].decode().unwrap();
872
873 let (hrp, data) = bech32::decode(&content).unwrap();
874 assert_eq!(hrp.to_string().to_uppercase(), "LNURL");
875
876 let decoded_bytes: Vec<u8> = data.into_iter().collect();
877 let decoded_url = String::from_utf8(decoded_bytes).unwrap();
878 assert_eq!(format!("http://localhost{request_url}"), decoded_url);
879 }
880
881 #[tokio::test]
882 async fn get_offer_when_invalid_partition_then_returns_not_found() {
883 let test_offer = create_test_offer();
884 let partition = test_offer.partition.clone();
885 let offer_id = test_offer.id;
886
887 let (server, _) = create_test_server_with_offer_and_expiry_and_balancer_and_partitions(
888 test_offer,
889 3600,
890 Some(["alternate-partition".to_string()].into()),
891 )
892 .await;
893
894 let response = server.get(&format!("/offers/{partition}/{offer_id}")).await;
895
896 assert_eq!(response.status_code(), StatusCode::NOT_FOUND);
897 }
898
899 #[tokio::test]
900 async fn get_invoice_when_invalid_partition_then_returns_not_found() {
901 let test_offer = create_test_offer();
902 let partition = test_offer.partition.clone();
903 let offer_id = test_offer.id;
904
905 let (server, _) = create_test_server_with_offer_and_expiry_and_balancer_and_partitions(
906 test_offer,
907 3600,
908 Some(["alternate-partition".to_string()].into()),
909 )
910 .await;
911
912 let response = server
913 .get(&format!(
914 "/offers/{partition}/{offer_id}/invoice?amount=500000"
915 ))
916 .await;
917
918 assert_eq!(response.status_code(), StatusCode::NOT_FOUND);
919 }
920
921 #[tokio::test]
922 async fn get_bech32_when_invalid_partition_then_returns_not_found() {
923 let test_offer = create_test_offer();
924 let partition = test_offer.partition.clone();
925 let offer_id = test_offer.id;
926
927 let (server, _) = create_test_server_with_offer_and_expiry_and_balancer_and_partitions(
928 test_offer,
929 3600,
930 Some(["alternate-partition".to_string()].into()),
931 )
932 .await;
933
934 let response = server
935 .get(&format!("/offers/{partition}/{offer_id}/bech32"))
936 .await;
937
938 assert_eq!(response.status_code(), StatusCode::NOT_FOUND);
939 }
940
941 #[tokio::test]
942 async fn get_bech32_qr_when_invalid_partition_then_returns_not_found() {
943 let test_offer = create_test_offer();
944 let partition = test_offer.partition.clone();
945 let offer_id = test_offer.id;
946
947 let (server, _) = create_test_server_with_offer_and_expiry_and_balancer_and_partitions(
948 test_offer,
949 3600,
950 Some(["alternate-partition".to_string()].into()),
951 )
952 .await;
953
954 let response = server
955 .get(&format!("/offers/{partition}/{offer_id}/bech32/qr"))
956 .await;
957
958 assert_eq!(response.status_code(), StatusCode::NOT_FOUND);
959 }
960}