1use crate::api::balance::LnBalancer;
2use crate::api::offer::OfferProvider;
3use crate::axum::partitions::PartitionsLayer;
4use crate::lnurl::pay::handler::LnUrlPayHandlers;
5use crate::lnurl::pay::state::LnUrlPayState;
6use axum::http::StatusCode;
7use axum::routing::get;
8use axum::Router;
9use std::sync::Arc;
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::api::balance::LnBalancer;
48 use crate::api::lnurl::{LnUrlInvoice, LnUrlOffer, LnUrlOfferMetadata};
49 use crate::api::offer::{
50 Offer, OfferMetadata, OfferMetadataSparse, OfferMetadataStore, OfferRecord,
51 OfferRecordSparse, OfferStore,
52 };
53 use crate::api::service::HasServiceErrorSource;
54 use crate::axum::extract::scheme::Scheme;
55 use crate::components::offer::memory::MemoryOfferStore;
56 use crate::lnurl::pay::state::LnUrlPayState;
57 use crate::lnurl::service::LnUrlBalancerService;
58 use async_trait::async_trait;
59 use axum::http::StatusCode;
60 use axum_test::TestServer;
61 use chrono::{Duration, Utc};
62 use std::collections::HashSet;
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) -> crate::api::service::ServiceErrorSource {
117 match self {
118 MockLnBalancerCombinedError::Internal => {
119 crate::api::service::ServiceErrorSource::Internal
120 }
121 MockLnBalancerCombinedError::Upstream => {
122 crate::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 timestamp: Utc::now() - Duration::hours(1),
179 expires: Some(Utc::now() + Duration::hours(1)),
180 },
181 };
182
183 (offer, metadata)
184 }
185
186 fn create_test_offer() -> OfferRecord {
187 let (offer, _) = create_test_offer_and_metadata();
188 offer
189 }
190
191 async fn create_test_server_with_offer(offer: OfferRecord) -> TestServer {
192 create_test_server_with_offer_and_expiry(offer, 3600).await
193 }
194
195 async fn create_test_server_with_offer_and_expiry(
196 offer: OfferRecord,
197 expiry: u64,
198 ) -> TestServer {
199 let (server, _) =
200 create_test_server_with_offer_and_expiry_and_balancer(offer, expiry).await;
201 server
202 }
203
204 async fn create_test_server_with_offer_and_expiry_and_balancer(
205 offer: OfferRecord,
206 expiry: u64,
207 ) -> (TestServer, MockLnBalancer) {
208 create_test_server_with_offer_and_expiry_and_balancer_and_partitions(offer, expiry, None)
209 .await
210 }
211
212 async fn create_test_server_with_offer_and_expiry_and_balancer_and_partitions(
213 offer: OfferRecord,
214 expiry: u64,
215 partitions: Option<HashSet<String>>,
216 ) -> (TestServer, MockLnBalancer) {
217 let partition = offer.partition.clone();
218 let offer_provider = MemoryOfferStore::default();
219
220 let metadata = OfferMetadata {
222 id: offer.offer.metadata_id,
223 partition: offer.partition.clone(),
224 metadata: OfferMetadataSparse {
225 text: "Test offer".to_string(),
226 long_text: Some("This is a test offer for LNURL Pay".to_string()),
227 image: None,
228 identifier: None,
229 },
230 };
231 offer_provider.put_metadata(metadata).await.unwrap();
232 offer_provider.put_offer(offer).await.unwrap();
233
234 let balancer = MockLnBalancer::new();
235 let partitions = partitions.unwrap_or_else(|| HashSet::from([partition.clone()]));
236 let state = LnUrlPayState::new(
237 partitions,
238 offer_provider,
239 balancer.clone(),
240 expiry,
241 Scheme("http".to_string()),
242 Default::default(),
243 Default::default(),
244 );
245
246 let app = LnUrlBalancerService::router(state);
247 let server = TestServer::new(app).unwrap();
248 (server, balancer)
249 }
250
251 fn create_empty_test_server() -> TestServer {
252 let offer_provider = MemoryOfferStore::default();
253 let balancer = MockLnBalancer::new();
254 let state = LnUrlPayState::new(
255 HashSet::from(["default".to_string()]),
256 offer_provider,
257 balancer,
258 3600,
259 Scheme("http".to_string()),
260 Default::default(),
261 Default::default(),
262 );
263
264 let app = LnUrlBalancerService::router(state);
265 TestServer::new(app).unwrap()
266 }
267
268 async fn create_test_server_with_failing_balancer(offer: OfferRecord) -> TestServer {
269 let partition = offer.partition.clone();
270 let offer_provider = MemoryOfferStore::default();
271
272 let metadata = OfferMetadata {
274 id: offer.offer.metadata_id,
275 partition: offer.partition.clone(),
276 metadata: OfferMetadataSparse {
277 text: "Test offer".to_string(),
278 long_text: Some("This is a test offer for LNURL Pay".to_string()),
279 image: None,
280 identifier: None,
281 },
282 };
283 offer_provider.put_metadata(metadata).await.unwrap();
284 offer_provider.put_offer(offer).await.unwrap();
285
286 let balancer = MockLnBalancer::with_failure();
287 let state = LnUrlPayState::new(
288 HashSet::from([partition.clone()]),
289 offer_provider,
290 balancer,
291 3600,
292 Scheme("http".to_string()),
293 Default::default(),
294 Default::default(),
295 );
296
297 let app = LnUrlBalancerService::router(state);
298 TestServer::new(app).unwrap()
299 }
300
301 #[tokio::test]
304 async fn health_check_when_called_then_returns_ok() {
305 let server = create_empty_test_server();
306 let response = server.get("/health").await;
307
308 assert_eq!(response.status_code(), StatusCode::OK);
309 assert_eq!(response.text(), "");
310 }
311
312 #[tokio::test]
315 async fn get_offer_when_exists_then_returns_lnurl_pay_request() {
316 let test_offer = create_test_offer();
317 let offer_id = test_offer.id;
318 let server = create_test_server_with_offer(test_offer.clone()).await;
319
320 let response = server.get(&format!("/offers/default/{offer_id}")).await;
321
322 assert_eq!(response.status_code(), StatusCode::OK);
323
324 let offer: LnUrlOffer = response.json();
326 assert!(
327 offer.callback.host_str().unwrap() == "127.0.0.1"
328 || offer.callback.host_str().unwrap() == "localhost"
329 );
330 assert!(offer
331 .callback
332 .path()
333 .contains(&format!("/offers/default/{offer_id}/invoice")));
334 assert_eq!(offer.max_sendable, test_offer.offer.max_sendable);
335 assert_eq!(offer.min_sendable, test_offer.offer.min_sendable);
336
337 let metadata: LnUrlOfferMetadata = serde_json::from_str(&offer.metadata).unwrap();
339 assert_eq!(metadata.0.text, "Test offer");
340 assert_eq!(
341 metadata.0.long_text,
342 Some("This is a test offer for LNURL Pay".to_string())
343 );
344 }
345
346 async fn create_test_server_with_scheme(
347 offer: OfferRecord,
348 scheme: &str,
349 ) -> (TestServer, Uuid) {
350 let partition = offer.partition.clone();
351 let offer_provider = MemoryOfferStore::default();
352 let metadata = OfferMetadata {
353 id: offer.offer.metadata_id,
354 partition: offer.partition.clone(),
355 metadata: OfferMetadataSparse {
356 text: "Test offer".to_string(),
357 long_text: Some("This is a test offer for LNURL Pay".to_string()),
358 image: None,
359 identifier: None,
360 },
361 };
362 offer_provider.put_metadata(metadata).await.unwrap();
363 offer_provider.put_offer(offer.clone()).await.unwrap();
364
365 let balancer = MockLnBalancer::new();
366 let state = LnUrlPayState::new(
367 HashSet::from([partition.clone()]),
368 offer_provider,
369 balancer,
370 3600,
371 Scheme(scheme.to_string()),
372 Default::default(),
373 Default::default(),
374 );
375
376 let app = LnUrlBalancerService::router(state);
377 let server = TestServer::new(app).unwrap();
378 (server, offer.id)
379 }
380
381 #[tokio::test]
382 async fn get_offer_callback_uses_default_scheme() {
383 let test_offer = create_test_offer();
384 let (server, offer_id) = create_test_server_with_scheme(test_offer, "https").await;
385
386 let response = server.get(&format!("/offers/default/{offer_id}")).await;
387 assert_eq!(response.status_code(), StatusCode::OK);
388
389 let offer: LnUrlOffer = response.json();
390 assert_eq!(offer.callback.scheme(), "https");
391 }
392
393 #[tokio::test]
394 async fn get_offer_callback_respects_x_forwarded_proto_header() {
395 let test_offer = create_test_offer();
396 let (server, offer_id) = create_test_server_with_scheme(test_offer, "http").await;
397
398 let response = server
399 .get(&format!("/offers/default/{offer_id}"))
400 .add_header("X-Forwarded-Proto", "https")
401 .await;
402 assert_eq!(response.status_code(), StatusCode::OK);
403
404 let offer: LnUrlOffer = response.json();
405 assert_eq!(offer.callback.scheme(), "https");
406 }
407
408 #[tokio::test]
409 async fn get_offer_callback_respects_forwarded_header() {
410 let test_offer = create_test_offer();
411 let (server, offer_id) = create_test_server_with_scheme(test_offer, "http").await;
412
413 let response = server
414 .get(&format!("/offers/default/{offer_id}"))
415 .add_header("Forwarded", "proto=wss;host=example.com")
416 .await;
417 assert_eq!(response.status_code(), StatusCode::OK);
418
419 let offer: LnUrlOffer = response.json();
420 assert_eq!(offer.callback.scheme(), "wss");
421 }
422
423 #[tokio::test]
424 async fn get_offer_callback_forwarded_header_takes_precedence() {
425 let test_offer = create_test_offer();
426 let (server, offer_id) = create_test_server_with_scheme(test_offer, "http").await;
427
428 let response = server
429 .get(&format!("/offers/default/{offer_id}"))
430 .add_header("Forwarded", "proto=wss")
431 .add_header("X-Forwarded-Proto", "https")
432 .await;
433 assert_eq!(response.status_code(), StatusCode::OK);
434
435 let offer: LnUrlOffer = response.json();
436 assert_eq!(offer.callback.scheme(), "wss");
437 }
438
439 #[tokio::test]
440 async fn get_offer_cache_headers_when_expires_in_30_minutes() {
441 let mut test_offer = create_test_offer();
442 test_offer.offer.expires = Some(Utc::now() + Duration::minutes(30));
444 let offer_id = test_offer.id;
445 let server = create_test_server_with_offer(test_offer).await;
446
447 let response = server.get(&format!("/offers/default/{offer_id}")).await;
448
449 assert_eq!(response.status_code(), StatusCode::OK);
450
451 let cache_control = response.header("cache-control");
453 let cache_control_str = cache_control.to_str().unwrap();
454 assert!(cache_control_str.starts_with("public, max-age="));
455 let max_age: u64 = cache_control_str
456 .strip_prefix("public, max-age=")
457 .unwrap()
458 .parse()
459 .unwrap();
460 assert!((1799..=1800).contains(&max_age));
462
463 let expires_header = response.header("expires");
465 let expires_header_str = expires_header.to_str().unwrap();
466 assert!(!expires_header_str.is_empty());
467 assert!(expires_header_str.ends_with(" GMT"));
468 }
469
470 #[tokio::test]
471 async fn get_offer_cache_headers_when_expires_in_5_minutes() {
472 let mut test_offer = create_test_offer();
473 test_offer.offer.expires = Some(Utc::now() + Duration::minutes(5));
475 let offer_id = test_offer.id;
476 let server = create_test_server_with_offer(test_offer).await;
477
478 let response = server.get(&format!("/offers/default/{offer_id}")).await;
479
480 assert_eq!(response.status_code(), StatusCode::OK);
481
482 let cache_control = response.header("cache-control");
484 let cache_control_str = cache_control.to_str().unwrap();
485 assert!(cache_control_str.starts_with("public, max-age="));
486 let max_age: u64 = cache_control_str
487 .strip_prefix("public, max-age=")
488 .unwrap()
489 .parse()
490 .unwrap();
491 assert!((299..=300).contains(&max_age));
493
494 let expires_header = response.header("expires");
496 let expires_header_str = expires_header.to_str().unwrap();
497 assert!(!expires_header_str.is_empty());
498 assert!(expires_header_str.ends_with(" GMT"));
499 }
500
501 #[tokio::test]
502 async fn get_offer_no_cache_headers_when_expires_is_none() {
503 let mut test_offer = create_test_offer();
504 test_offer.offer.expires = None;
506 let offer_id = test_offer.id;
507 let server = create_test_server_with_offer(test_offer).await;
508
509 let response = server.get(&format!("/offers/default/{offer_id}")).await;
510 assert_eq!(response.status_code(), StatusCode::OK);
511
512 let cache_control = response.header("cache-control");
515 let cache_control_str = cache_control.to_str().unwrap();
516 assert_eq!(cache_control_str, "no-store, no-cache, must-revalidate");
517
518 let expires_header = response.header("expires");
520 let expires_header_str = expires_header.to_str().unwrap();
521 assert_eq!(expires_header_str, "Thu, 01 Jan 1970 00:00:00 GMT");
522
523 let pragma_header = response.header("pragma");
525 let pragma_header_str = pragma_header.to_str().unwrap();
526 assert_eq!(pragma_header_str, "no-cache");
527 }
528
529 #[tokio::test]
530 async fn get_offer_when_not_exists_then_returns_not_found() {
531 let server = create_empty_test_server();
532 let non_existent_id = Uuid::new_v4();
533
534 let response = server
535 .get(&format!("/offers/default/{non_existent_id}"))
536 .await;
537
538 assert_eq!(response.status_code(), StatusCode::NOT_FOUND);
539 }
540
541 #[tokio::test]
542 async fn get_offer_when_expired_then_returns_gone() {
543 let mut test_offer = create_test_offer();
544 test_offer.offer.expires = Some(Utc::now() - Duration::hours(1));
546 let offer_id = test_offer.id;
547 let server = create_test_server_with_offer(test_offer).await;
548
549 let response = server.get(&format!("/offers/default/{offer_id}")).await;
550
551 assert_eq!(response.status_code(), StatusCode::NOT_FOUND);
552 }
553
554 #[tokio::test]
555 async fn get_offer_when_invalid_uuid_then_returns_not_found() {
556 let server = create_empty_test_server();
557
558 let response = server.get("/offers/default/invalid-uuid").await;
559
560 assert_eq!(response.status_code(), StatusCode::NOT_FOUND);
561 }
562
563 #[tokio::test]
566 async fn get_invoice_when_valid_request_then_returns_invoice() {
567 let test_offer = create_test_offer();
568 let offer_id = test_offer.id;
569 let server = create_test_server_with_offer(test_offer).await;
570
571 let response = server
572 .get(&format!("/offers/default/{offer_id}/invoice?amount=500000",))
573 .await;
574
575 assert_eq!(response.status_code(), StatusCode::OK);
576
577 let invoice: LnUrlInvoice = response.json();
579 assert!(invoice.pr.starts_with("lnbc"));
580 assert_eq!(invoice.routes.len(), 0);
581 }
582
583 #[tokio::test]
584 async fn get_invoice_when_offer_not_exists_then_returns_not_found() {
585 let server = create_empty_test_server();
586 let non_existent_id = Uuid::new_v4();
587
588 let response = server
589 .get(&format!(
590 "/offers/default/{non_existent_id}/invoice?amount=500000",
591 ))
592 .await;
593
594 assert_eq!(response.status_code(), StatusCode::NOT_FOUND);
595 }
596
597 #[tokio::test]
598 async fn get_invoice_when_amount_missing_then_returns_bad_request() {
599 let test_offer = create_test_offer();
600 let offer_id = test_offer.id;
601 let server = create_test_server_with_offer(test_offer).await;
602
603 let response = server
604 .get(&format!("/offers/default/{offer_id}/invoice"))
605 .await;
606
607 assert_eq!(response.status_code(), StatusCode::BAD_REQUEST);
609 }
610
611 #[tokio::test]
612 async fn get_invoice_when_amount_valid_then_passes_to_balancer() {
613 let test_offer = create_test_offer();
614 let offer_id = test_offer.id;
615 let server = create_test_server_with_offer(test_offer.clone()).await;
616
617 let response = server
619 .get(&format!(
620 "/offers/default/{}/invoice?amount={}",
621 offer_id, test_offer.offer.min_sendable
622 ))
623 .await;
624
625 assert_eq!(response.status_code(), StatusCode::OK);
626
627 let invoice: LnUrlInvoice = response.json();
628 assert!(invoice.pr.starts_with("lnbc"));
629 assert_eq!(invoice.routes.len(), 0);
630 }
631
632 #[tokio::test]
633 async fn get_invoice_when_amount_outside_range_then_returns_bad_request() {
634 let test_offer = create_test_offer();
635 let offer_id = test_offer.id;
636 let server = create_test_server_with_offer(test_offer.clone()).await;
637
638 let response = server
640 .get(&format!(
641 "/offers/default/{}/invoice?amount={}",
642 offer_id,
643 test_offer.offer.max_sendable + 1
644 ))
645 .await;
646
647 assert_eq!(response.status_code(), StatusCode::BAD_REQUEST);
648
649 let response = server
651 .get(&format!(
652 "/offers/default/{}/invoice?amount={}",
653 offer_id,
654 test_offer.offer.min_sendable - 1
655 ))
656 .await;
657
658 assert_eq!(response.status_code(), StatusCode::BAD_REQUEST);
659 }
660
661 #[tokio::test]
662 async fn get_invoice_when_invalid_amount_then_returns_bad_request() {
663 let test_offer = create_test_offer();
664 let offer_id = test_offer.id;
665 let server = create_test_server_with_offer(test_offer).await;
666
667 let response = server
668 .get(&format!(
669 "/offers/default/{offer_id}/invoice?amount=invalid",
670 ))
671 .await;
672
673 assert_eq!(response.status_code(), StatusCode::BAD_REQUEST);
674 }
675
676 #[tokio::test]
677 async fn get_invoice_when_expired_offer_then_returns_not_found() {
678 let mut test_offer = create_test_offer();
679 test_offer.offer.expires = Some(Utc::now() - Duration::hours(1));
681 let offer_id = test_offer.id;
682 let server = create_test_server_with_offer(test_offer).await;
683
684 let response = server
685 .get(&format!("/offers/default/{offer_id}/invoice?amount=500000",))
686 .await;
687
688 assert_eq!(response.status_code(), StatusCode::NOT_FOUND);
689 }
690
691 #[tokio::test]
692 async fn get_invoice_when_balancer_fails_then_returns_internal_server_error() {
693 let test_offer = create_test_offer();
694 let offer_id = test_offer.id;
695 let server = create_test_server_with_failing_balancer(test_offer).await;
696
697 let response = server
698 .get(&format!("/offers/default/{offer_id}/invoice?amount=500000",))
699 .await;
700
701 assert_eq!(response.status_code(), StatusCode::INTERNAL_SERVER_ERROR);
702 }
703
704 #[tokio::test]
705 async fn get_invoice_when_invalid_uuid_then_returns_not_found() {
706 let server = create_empty_test_server();
707
708 let response = server
709 .get("/offers/default/invalid-uuid/invoice?amount=500000")
710 .await;
711
712 assert_eq!(response.status_code(), StatusCode::NOT_FOUND);
713 }
714
715 #[tokio::test]
718 async fn get_invoice_with_custom_invoice_response() {
719 let test_offer = create_test_offer();
720 let offer_provider = MemoryOfferStore::default();
721
722 let partition = test_offer.partition.clone();
723
724 let metadata = OfferMetadata {
726 id: test_offer.offer.metadata_id,
727 partition: test_offer.partition.clone(),
728 metadata: OfferMetadataSparse {
729 text: "Test offer".to_string(),
730 long_text: Some("This is a test offer for LNURL Pay".to_string()),
731 image: None,
732 identifier: None,
733 },
734 };
735 offer_provider.put_metadata(metadata).await.unwrap();
736 offer_provider.put_offer(test_offer.clone()).await.unwrap();
737
738 let custom_invoice = "lnbc5000n1pjdkqs0pp5custom...";
739 let balancer = MockLnBalancer::with_invoice(custom_invoice);
740 let state = LnUrlPayState::new(
741 HashSet::from([partition]),
742 offer_provider,
743 balancer,
744 3600,
745 Scheme("http".to_string()),
746 Default::default(),
747 Default::default(),
748 );
749 let app = LnUrlBalancerService::router(state);
750 let server = TestServer::new(app).unwrap();
751
752 let offer_id = test_offer.id;
753
754 let response = server
755 .get(&format!("/offers/default/{offer_id}/invoice?amount=500000",))
756 .await;
757
758 assert_eq!(response.status_code(), StatusCode::OK);
759
760 let invoice: LnUrlInvoice = response.json();
761 assert_eq!(invoice.pr, custom_invoice);
762 }
763
764 #[tokio::test]
765 async fn get_invoice_when_valid_request_then_uses_configured_expiry() {
766 let test_offer = create_test_offer();
767 let offer_id = test_offer.id;
768 let expected_expiry = 7200u64; let (server, balancer) =
770 create_test_server_with_offer_and_expiry_and_balancer(test_offer, expected_expiry)
771 .await;
772
773 let response = server
774 .get(&format!("/offers/default/{offer_id}/invoice?amount=500000",))
775 .await;
776
777 assert_eq!(response.status_code(), StatusCode::OK);
778
779 assert_eq!(balancer.captured_expiry(), Some(expected_expiry));
781 }
782
783 #[tokio::test]
786 async fn get_bech32_when_valid_offer_then_returns_bech32_string() {
787 let test_offer = create_test_offer();
788 let offer_id = test_offer.id;
789 let server = create_test_server_with_offer(test_offer).await;
790
791 let request_url = format!("/offers/default/{offer_id}");
792 let request_url_bech32 = format!("{request_url}/bech32");
793
794 let response = server.get(&request_url_bech32).await;
795
796 assert_eq!(response.status_code(), StatusCode::OK);
797 assert_eq!(
798 response.header("content-type").to_str().unwrap(),
799 "text/plain; charset=utf-8"
800 );
801
802 let bech32_str = response.text();
803 let (hrp, data) = bech32::decode(&bech32_str).unwrap();
804 assert_eq!(hrp.to_string().to_uppercase(), "LNURL");
805
806 let decoded_bytes: Vec<u8> = data.into_iter().collect();
807 let decoded_url = String::from_utf8(decoded_bytes).unwrap();
808 assert_eq!(format!("http://localhost{request_url}"), decoded_url);
809 }
810
811 #[tokio::test]
812 async fn get_bech32_qr_when_valid_offer_then_returns_png_image() {
813 let test_offer = create_test_offer();
814 let offer_id = test_offer.id;
815 let server = create_test_server_with_offer(test_offer).await;
816
817 let request_url = format!("/offers/default/{offer_id}");
818 let request_url_bech32_qr = format!("{request_url}/bech32/qr");
819
820 let response = server.get(&request_url_bech32_qr).await;
821
822 assert_eq!(response.status_code(), StatusCode::OK);
823 assert_eq!(
824 response.header("content-type").to_str().unwrap(),
825 "image/png"
826 );
827
828 let png_bytes = response.as_bytes();
829
830 use image::ImageReader;
832 use std::io::Cursor;
833
834 let img = ImageReader::new(Cursor::new(&png_bytes))
835 .with_guessed_format()
836 .unwrap()
837 .decode()
838 .unwrap();
839
840 let gray_img = img.to_luma8();
841 use rqrr::PreparedImage;
842
843 let mut prepared = PreparedImage::prepare(gray_img);
844 let grids = prepared.detect_grids();
845 assert!(!grids.is_empty(), "Should detect at least one QR code");
846
847 let (_, content) = grids[0].decode().unwrap();
848
849 let (hrp, data) = bech32::decode(&content).unwrap();
850 assert_eq!(hrp.to_string().to_uppercase(), "LNURL");
851
852 let decoded_bytes: Vec<u8> = data.into_iter().collect();
853 let decoded_url = String::from_utf8(decoded_bytes).unwrap();
854 assert_eq!(format!("http://localhost{request_url}"), decoded_url);
855 }
856
857 #[tokio::test]
858 async fn get_offer_when_invalid_partition_then_returns_not_found() {
859 let test_offer = create_test_offer();
860 let partition = test_offer.partition.clone();
861 let offer_id = test_offer.id;
862
863 let (server, _) = create_test_server_with_offer_and_expiry_and_balancer_and_partitions(
864 test_offer,
865 3600,
866 Some(["alternate-partition".to_string()].into()),
867 )
868 .await;
869
870 let response = server.get(&format!("/offers/{partition}/{offer_id}")).await;
871
872 assert_eq!(response.status_code(), StatusCode::NOT_FOUND);
873 }
874
875 #[tokio::test]
876 async fn get_invoice_when_invalid_partition_then_returns_not_found() {
877 let test_offer = create_test_offer();
878 let partition = test_offer.partition.clone();
879 let offer_id = test_offer.id;
880
881 let (server, _) = create_test_server_with_offer_and_expiry_and_balancer_and_partitions(
882 test_offer,
883 3600,
884 Some(["alternate-partition".to_string()].into()),
885 )
886 .await;
887
888 let response = server
889 .get(&format!(
890 "/offers/{partition}/{offer_id}/invoice?amount=500000"
891 ))
892 .await;
893
894 assert_eq!(response.status_code(), StatusCode::NOT_FOUND);
895 }
896
897 #[tokio::test]
898 async fn get_bech32_when_invalid_partition_then_returns_not_found() {
899 let test_offer = create_test_offer();
900 let partition = test_offer.partition.clone();
901 let offer_id = test_offer.id;
902
903 let (server, _) = create_test_server_with_offer_and_expiry_and_balancer_and_partitions(
904 test_offer,
905 3600,
906 Some(["alternate-partition".to_string()].into()),
907 )
908 .await;
909
910 let response = server
911 .get(&format!("/offers/{partition}/{offer_id}/bech32"))
912 .await;
913
914 assert_eq!(response.status_code(), StatusCode::NOT_FOUND);
915 }
916
917 #[tokio::test]
918 async fn get_bech32_qr_when_invalid_partition_then_returns_not_found() {
919 let test_offer = create_test_offer();
920 let partition = test_offer.partition.clone();
921 let offer_id = test_offer.id;
922
923 let (server, _) = create_test_server_with_offer_and_expiry_and_balancer_and_partitions(
924 test_offer,
925 3600,
926 Some(["alternate-partition".to_string()].into()),
927 )
928 .await;
929
930 let response = server
931 .get(&format!("/offers/{partition}/{offer_id}/bech32/qr"))
932 .await;
933
934 assert_eq!(response.status_code(), StatusCode::NOT_FOUND);
935 }
936}