switchgear_service/lnurl/
service.rs

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    // 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) -> 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            // 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                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        // Create metadata for the offer
222        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        // Create metadata for the offer
280        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    // Health Check Tests
312
313    #[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    // Offer Endpoint Tests
323
324    #[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        // Verify response structure (LNURL Pay spec)
335        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        // Deserialize the metadata string to verify it matches our test data
348        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        // Set offer to expire in 30 minutes
457        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        // Check Cache-Control header
466        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        // Should be between 1799 and 1800 seconds (30 minutes, allowing for timing)
475        assert!((1799..=1800).contains(&max_age));
476
477        // Check Expires header
478        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        // Set offer to expire in 5 minutes
488        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        // Check Cache-Control header
497        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        // Should be between 299 and 300 seconds (5 minutes, allowing for timing)
506        assert!((299..=300).contains(&max_age));
507
508        // Check Expires header
509        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        // Set offer expires to None (no expiration)
519        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        // Check no-cache headers are present
527        // Cache-Control header should be "no-store, no-cache, must-revalidate"
528        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        // Expires header should be "Thu, 01 Jan 1970 00:00:00 GMT"
533        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        // Pragma header should be "no-cache"
538        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        // Make the offer expired
559        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    // Invoice Endpoint Tests
578
579    #[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        // Verify response structure (LNURL Pay spec)
592        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        // Axum query parameter validation would result in 400 for missing required parameter
622        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        // Test with amount within range
632        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        // Test amount above max_sendable
653        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        // Test amount below min_sendable
664        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        // Make the offer expired
694        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    // Additional edge case tests
730
731    #[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        // Create metadata for the offer
739        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; // 2 hours
786        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        // Verify that the balancer received the correct expiry value
797        assert_eq!(balancer.captured_expiry(), Some(expected_expiry));
798    }
799
800    // Bech32 Endpoint Tests
801
802    #[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        // Decode the QR code from the PNG to verify content
848        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        // Convert to rqrr-compatible image
863        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}