switchgear_service/lnurl/
service.rs

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    // Mock LnBalancer implementation
66    #[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            // Capture the expiry parameter for testing
140            *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    // Test helper functions
157    fn create_test_offer_and_metadata() -> (OfferRecord, OfferMetadata) {
158        // Create metadata first
159        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        // Create metadata for the offer
221        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        // Create metadata for the offer
273        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    // Health Check Tests
302
303    #[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    // Offer Endpoint Tests
313
314    #[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        // Verify response structure (LNURL Pay spec)
325        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        // Deserialize the metadata string to verify it matches our test data
338        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        // Set offer to expire in 30 minutes
443        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        // Check Cache-Control header
452        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        // Should be between 1799 and 1800 seconds (30 minutes, allowing for timing)
461        assert!((1799..=1800).contains(&max_age));
462
463        // Check Expires header
464        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        // Set offer to expire in 5 minutes
474        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        // Check Cache-Control header
483        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        // Should be between 299 and 300 seconds (5 minutes, allowing for timing)
492        assert!((299..=300).contains(&max_age));
493
494        // Check Expires header
495        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        // Set offer expires to None (no expiration)
505        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        // Check no-cache headers are present
513        // Cache-Control header should be "no-store, no-cache, must-revalidate"
514        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        // Expires header should be "Thu, 01 Jan 1970 00:00:00 GMT"
519        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        // Pragma header should be "no-cache"
524        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        // Make the offer expired
545        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    // Invoice Endpoint Tests
564
565    #[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        // Verify response structure (LNURL Pay spec)
578        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        // Axum query parameter validation would result in 400 for missing required parameter
608        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        // Test with amount within range
618        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        // Test amount above max_sendable
639        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        // Test amount below min_sendable
650        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        // Make the offer expired
680        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    // Additional edge case tests
716
717    #[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        // Create metadata for the offer
725        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; // 2 hours
769        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        // Verify that the balancer received the correct expiry value
780        assert_eq!(balancer.captured_expiry(), Some(expected_expiry));
781    }
782
783    // Bech32 Endpoint Tests
784
785    #[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        // Decode the QR code from the PNG to verify content
831        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}