Skip to main content

bsv_messagebox_client/
types.rs

1use std::collections::HashMap;
2
3use serde::{Deserialize, Serialize};
4
5// ---------------------------------------------------------------------------
6// Phase 6 — parity types: sendList, multi-quote, payment metadata
7// ---------------------------------------------------------------------------
8
9/// Parameters for sending a message to multiple recipients in one call.
10#[derive(Serialize, Deserialize, Clone, Debug)]
11#[serde(rename_all = "camelCase")]
12pub struct SendListParams {
13    pub recipients: Vec<String>,
14    pub message_box: String,
15    pub body: String,
16    #[serde(skip_serializing_if = "Option::is_none")]
17    pub skip_encryption: Option<bool>,
18}
19
20/// A single successfully-delivered recipient entry in a sendList result.
21#[derive(Serialize, Deserialize, Clone, Debug)]
22#[serde(rename_all = "camelCase")]
23pub struct SentRecipient {
24    pub recipient: String,
25    pub message_id: String,
26}
27
28/// A failed recipient entry in a sendList result.
29#[derive(Serialize, Deserialize, Clone, Debug)]
30#[serde(rename_all = "camelCase")]
31pub struct FailedRecipient {
32    pub recipient: String,
33    pub error: String,
34}
35
36/// Aggregate fee totals returned in a sendList or multi-quote response.
37#[derive(Serialize, Deserialize, Clone, Debug)]
38#[serde(rename_all = "camelCase")]
39pub struct SendListTotals {
40    pub delivery_fees: i64,
41    pub recipient_fees: i64,
42    pub total_for_payable_recipients: i64,
43}
44
45/// Result of a sendList operation.
46#[derive(Serialize, Deserialize, Clone, Debug)]
47#[serde(rename_all = "camelCase")]
48pub struct SendListResult {
49    pub status: String,
50    pub description: String,
51    pub sent: Vec<SentRecipient>,
52    pub blocked: Vec<String>,
53    pub failed: Vec<FailedRecipient>,
54    #[serde(skip_serializing_if = "Option::is_none")]
55    pub totals: Option<SendListTotals>,
56}
57
58/// Quote for a single recipient in a multi-recipient quote request.
59#[derive(Serialize, Deserialize, Clone, Debug)]
60#[serde(rename_all = "camelCase")]
61pub struct RecipientQuote {
62    pub recipient: String,
63    pub message_box: String,
64    pub delivery_fee: i64,
65    pub recipient_fee: i64,
66    pub status: String,
67}
68
69/// Aggregated delivery quotes for multiple recipients.
70#[derive(Serialize, Deserialize, Clone, Debug)]
71#[serde(rename_all = "camelCase")]
72pub struct MessageBoxMultiQuote {
73    pub quotes_by_recipient: Vec<RecipientQuote>,
74    #[serde(skip_serializing_if = "Option::is_none")]
75    pub totals: Option<SendListTotals>,
76    pub blocked_recipients: Vec<String>,
77    pub delivery_agent_identity_key_by_host: HashMap<String, String>,
78}
79
80/// Remittance output describing how a payment was routed to a recipient.
81///
82/// Carries the derivation keys needed to prove the counterparty can redeem.
83#[derive(Serialize, Deserialize, Clone, Debug)]
84#[serde(rename_all = "camelCase")]
85pub struct PaymentRemittanceInfo {
86    pub derivation_prefix: String,
87    pub derivation_suffix: String,
88    pub sender_identity_key: String,
89}
90
91/// Basket insertion remittance — used when the output targets a basket.
92#[derive(Serialize, Deserialize, Clone, Debug)]
93#[serde(rename_all = "camelCase")]
94pub struct InsertionRemittanceInfo {
95    pub basket: String,
96    #[serde(skip_serializing_if = "Option::is_none")]
97    pub custom_instructions: Option<String>,
98    #[serde(skip_serializing_if = "Option::is_none")]
99    pub tags: Option<Vec<String>>,
100}
101
102/// A single output within a Payment struct.
103#[derive(Serialize, Deserialize, Clone, Debug)]
104#[serde(rename_all = "camelCase")]
105pub struct PaymentOutput {
106    pub output_index: u32,
107    pub protocol: String,
108    #[serde(skip_serializing_if = "Option::is_none")]
109    pub payment_remittance: Option<PaymentRemittanceInfo>,
110    #[serde(skip_serializing_if = "Option::is_none")]
111    pub insertion_remittance: Option<InsertionRemittanceInfo>,
112}
113
114/// A payment token for attaching BSV remittance to a sendList call.
115///
116/// Distinct from `PaymentToken` (PeerPay p2p) — this type represents an
117/// on-chain payment submitted alongside a multi-recipient send.
118#[derive(Serialize, Deserialize, Clone, Debug)]
119#[serde(rename_all = "camelCase")]
120pub struct Payment {
121    pub tx: Vec<u8>,
122    pub outputs: Vec<PaymentOutput>,
123    pub description: String,
124    #[serde(skip_serializing_if = "Option::is_none")]
125    pub labels: Option<Vec<String>>,
126    #[serde(skip_serializing_if = "Option::is_none")]
127    pub seek_permission: Option<bool>,
128}
129
130// ---------------------------------------------------------------------------
131// Phase 5 — overlay advertisement and device registration types
132// ---------------------------------------------------------------------------
133
134/// An advertisement token recovered from an overlay lookup.
135///
136/// Represents a single UTXO that encodes a MessageBox host advertisement via
137/// PushDrop: fields[0] = identity key bytes, fields[1] = host URL bytes.
138#[derive(Clone, Debug)]
139pub struct AdvertisementToken {
140    /// The host URL encoded in the advertisement.
141    pub host: String,
142    /// Transaction ID of the advertising UTXO.
143    pub txid: String,
144    /// Output index within the advertising transaction.
145    pub output_index: u32,
146    /// Hex-encoded locking script of the advertising output.
147    pub locking_script: String,
148    /// Raw BEEF bytes for the advertising transaction (needed for revocation).
149    pub beef: Vec<u8>,
150}
151
152/// Request body for registering an FCM device token.
153///
154/// Serializes to camelCase JSON for POST to `{host}/registerDevice`.
155/// Optional fields are omitted when None per TS wire format.
156#[derive(Serialize, Clone, Debug)]
157#[serde(rename_all = "camelCase")]
158pub struct RegisterDeviceRequest {
159    pub fcm_token: String,
160    #[serde(skip_serializing_if = "Option::is_none")]
161    pub device_id: Option<String>,
162    #[serde(skip_serializing_if = "Option::is_none")]
163    pub platform: Option<String>,
164}
165
166/// A registered device record as returned by the server's `/devices` endpoint.
167///
168/// All fields are `Option` because the server may omit any of them.
169/// Includes ALL known server fields to prevent deserialization failures
170/// against go-messagebox-server.
171#[derive(Deserialize, Debug, Clone)]
172#[serde(rename_all = "camelCase")]
173pub struct RegisteredDevice {
174    pub id: Option<i64>,
175    pub device_id: Option<String>,
176    pub platform: Option<String>,
177    pub fcm_token: String,
178    pub active: Option<bool>,
179    pub created_at: Option<String>,
180    pub updated_at: Option<String>,
181    pub last_used: Option<String>,
182}
183
184/// Response from the `/registerDevice` endpoint.
185///
186/// TS returns `{ status, message, deviceId }`.
187#[derive(Deserialize, Clone, Debug)]
188#[serde(rename_all = "camelCase")]
189pub struct RegisterDeviceResponse {
190    pub status: String,
191    pub message: Option<String>,
192    pub device_id: Option<i64>,
193}
194
195/// Response from the `/devices` (list registered devices) endpoint.
196#[derive(Deserialize, Clone, Debug)]
197#[serde(rename_all = "camelCase")]
198pub struct ListDevicesResponse {
199    pub status: String,
200    pub devices: Vec<RegisteredDevice>,
201}
202
203// ---------------------------------------------------------------------------
204// Phase 2 — permission / quote types
205// ---------------------------------------------------------------------------
206
207/// Parameters for setting a permission rule on a message box.
208///
209/// Serializes to camelCase for POST body to `/permissions/set`.
210/// `sender` is omitted when `None` — the server treats absence as "any sender".
211#[derive(Serialize, Deserialize, Clone, Debug)]
212#[serde(rename_all = "camelCase")]
213pub struct SetPermissionParams {
214    pub message_box: String,
215    #[serde(skip_serializing_if = "Option::is_none")]
216    pub sender: Option<String>,
217    /// -1 = block, 0 = always allow, >0 = satoshi fee required
218    pub recipient_fee: i64,
219}
220
221/// A permission record as returned by `/permissions/get` (camelCase) or
222/// `/permissions/list` (snake_case).
223///
224/// Uses per-field `#[serde(alias)]` instead of `rename_all` so the same
225/// struct deserializes from BOTH server endpoint formats.
226#[derive(Serialize, Deserialize, Clone, Debug)]
227pub struct MessageBoxPermission {
228    #[serde(skip_serializing_if = "Option::is_none")]
229    pub sender: Option<String>,
230    #[serde(alias = "messageBox")]
231    pub message_box: String,
232    #[serde(alias = "recipientFee")]
233    pub recipient_fee: i64,
234    #[serde(alias = "createdAt")]
235    pub created_at: String,
236    #[serde(alias = "updatedAt")]
237    pub updated_at: String,
238}
239
240impl MessageBoxPermission {
241    /// Compute the permission status from the `recipient_fee` value.
242    ///
243    /// Mirrors the TypeScript SDK's `getStatusFromFee()`:
244    /// -1 → "blocked", 0 → "always_allow", >0 → "payment_required"
245    pub fn status(&self) -> &str {
246        match self.recipient_fee {
247            f if f < 0 => "blocked",
248            0 => "always_allow",
249            _ => "payment_required",
250        }
251    }
252}
253
254/// Quote for delivering a message to a recipient.
255///
256/// NOT deserialized directly from JSON — constructed manually after parsing
257/// the wrapped `{"quote": {"recipientFee": N, "deliveryFee": N}}` response
258/// body and extracting the `x-bsv-auth-identity-key` response header.
259#[derive(Clone, Debug)]
260pub struct MessageBoxQuote {
261    pub delivery_fee: i64,
262    pub recipient_fee: i64,
263    /// Populated from the `x-bsv-auth-identity-key` response header, not the body.
264    pub delivery_agent_identity_key: String,
265}
266
267/// Parameters for sending a message to a recipient's inbox.
268#[derive(Serialize, Deserialize, Clone, Debug)]
269#[serde(rename_all = "camelCase")]
270pub struct SendMessageParams {
271    pub recipient: String,
272    pub message_box: String,
273    pub body: String,
274    pub message_id: String,
275}
276
277/// Payment structure included in a sendMessage request when check_permissions requires a fee.
278///
279/// Serializes the transaction bytes and outputs to wire format matching TS Payment type.
280#[derive(Serialize, Deserialize, Clone, Debug)]
281#[serde(rename_all = "camelCase")]
282pub struct MessagePayment {
283    /// Raw transaction bytes.
284    pub tx: Vec<u8>,
285    /// Outputs from the payment transaction.
286    pub outputs: Vec<MessagePaymentOutput>,
287}
288
289/// One output entry in a MessagePayment.
290#[derive(Serialize, Deserialize, Clone, Debug)]
291#[serde(rename_all = "camelCase")]
292pub struct MessagePaymentOutput {
293    pub output_index: u32,
294    pub derivation_prefix: Vec<u8>,
295    pub derivation_suffix: Vec<u8>,
296    pub sender_identity_key: String,
297}
298
299/// Wire format wrapper — serializes as `{"message": <params>}` for the `/sendMessage` endpoint.
300#[derive(Serialize, Deserialize, Clone, Debug)]
301pub struct SendMessageRequest {
302    pub message: SendMessageParams,
303    #[serde(skip_serializing_if = "Option::is_none")]
304    pub payment: Option<MessagePayment>,
305}
306
307
308/// Parameters for listing messages from a specific inbox.
309#[derive(Serialize, Deserialize, Clone, Debug)]
310#[serde(rename_all = "camelCase")]
311pub struct ListMessagesParams {
312    pub message_box: String,
313}
314
315/// Parameters for acknowledging (marking as read) a set of messages.
316#[derive(Serialize, Deserialize, Clone, Debug)]
317#[serde(rename_all = "camelCase")]
318pub struct AcknowledgeMessageParams {
319    pub message_ids: Vec<String>,
320}
321
322/// A message as returned by the server's list endpoint.
323///
324/// Uses explicit `serde(rename)` on camelCase fields because the server mixes
325/// naming conventions: `messageId`, `sender` are camelCase but `created_at` /
326/// `updated_at` are snake_case. Do NOT use `deny_unknown_fields` — the server
327/// may add new fields at any time.
328#[derive(Serialize, Deserialize, Clone, Debug)]
329pub struct ServerPeerMessage {
330    #[serde(rename = "messageId")]
331    pub message_id: String,
332    pub body: String,
333    pub sender: String,
334    #[serde(alias = "createdAt")]
335    pub created_at: String,
336    #[serde(alias = "updatedAt")]
337    pub updated_at: String,
338    #[serde(skip_serializing_if = "Option::is_none")]
339    pub acknowledged: Option<bool>,
340}
341
342/// Response from the `/listMessages` endpoint.
343#[derive(Serialize, Deserialize, Clone, Debug)]
344#[serde(rename_all = "camelCase")]
345pub struct ListMessagesResponse {
346    pub status: String,
347    pub messages: Vec<ServerPeerMessage>,
348}
349
350/// Response from the `/sendMessage` endpoint.
351#[derive(Serialize, Deserialize, Clone, Debug)]
352#[serde(rename_all = "camelCase")]
353pub struct SendMessageResponse {
354    pub status: String,
355    /// The server-assigned message ID (may be absent on some server versions).
356    #[serde(skip_serializing_if = "Option::is_none")]
357    pub message_id: Option<String>,
358}
359
360// ---------------------------------------------------------------------------
361// Phase 4 — WebSocket live messaging wire types
362// ---------------------------------------------------------------------------
363
364/// Payload for the sendMessage WebSocket event.
365///
366/// Serialized as JSON and passed to socket.emit("sendMessage", ...).
367/// Wire format: {"roomId": "recipient-inbox", "message": {"messageId": "...", ...}}
368#[derive(Serialize, Deserialize, Clone, Debug)]
369#[serde(rename_all = "camelCase")]
370pub struct WsSendMessageData {
371    pub room_id: String,
372    pub message: WsSendMessagePayload,
373}
374
375/// The message object inside a sendMessage event.
376#[derive(Serialize, Deserialize, Clone, Debug)]
377#[serde(rename_all = "camelCase")]
378pub struct WsSendMessagePayload {
379    pub message_id: String,
380    pub recipient: String,
381    pub body: String,
382}
383
384// ---------------------------------------------------------------------------
385// Phase 7 — Payment Request types (PeerPay Request System)
386// ---------------------------------------------------------------------------
387
388/// Message box names for the payment request system.
389pub const PAYMENT_REQUESTS_MESSAGEBOX: &str = "payment_requests";
390pub const PAYMENT_REQUEST_RESPONSES_MESSAGEBOX: &str = "payment_request_responses";
391
392/// Default limits for filtering incoming payment requests.
393pub const DEFAULT_PAYMENT_REQUEST_MIN_AMOUNT: u64 = 1000;
394pub const DEFAULT_PAYMENT_REQUEST_MAX_AMOUNT: u64 = 10_000_000;
395
396/// A payment request message sent to a recipient's `payment_requests` inbox.
397///
398/// Discriminated union: if `cancelled` is `Some(true)`, this is a cancellation
399/// message and `amount`/`description`/`expires_at` will be absent.
400#[derive(Serialize, Deserialize, Clone, Debug)]
401#[serde(rename_all = "camelCase")]
402pub struct PaymentRequestMessage {
403    pub request_id: String,
404    pub sender_identity_key: String,
405    pub request_proof: String,
406    #[serde(skip_serializing_if = "Option::is_none")]
407    pub amount: Option<u64>,
408    #[serde(skip_serializing_if = "Option::is_none")]
409    pub description: Option<String>,
410    #[serde(skip_serializing_if = "Option::is_none")]
411    pub expires_at: Option<u64>,
412    #[serde(skip_serializing_if = "Option::is_none")]
413    pub cancelled: Option<bool>,
414}
415
416/// Response to a payment request, sent to the requester's
417/// `payment_request_responses` inbox.
418#[derive(Serialize, Deserialize, Clone, Debug)]
419#[serde(rename_all = "camelCase")]
420pub struct PaymentRequestResponse {
421    pub request_id: String,
422    pub status: String, // "paid" | "declined"
423    #[serde(skip_serializing_if = "Option::is_none")]
424    pub note: Option<String>,
425    #[serde(skip_serializing_if = "Option::is_none")]
426    pub amount_paid: Option<u64>,
427}
428
429/// A validated incoming payment request (after filtering and HMAC verification).
430#[derive(Clone, Debug)]
431pub struct IncomingPaymentRequest {
432    pub message_id: String,
433    pub sender: String,
434    pub request_id: String,
435    pub amount: u64,
436    pub description: String,
437    pub expires_at: u64,
438}
439
440/// Optional limits for filtering incoming payment requests.
441#[derive(Clone, Debug)]
442pub struct PaymentRequestLimits {
443    pub min_amount: u64,
444    pub max_amount: u64,
445}
446
447impl Default for PaymentRequestLimits {
448    fn default() -> Self {
449        Self {
450            min_amount: DEFAULT_PAYMENT_REQUEST_MIN_AMOUNT,
451            max_amount: DEFAULT_PAYMENT_REQUEST_MAX_AMOUNT,
452        }
453    }
454}
455
456/// Result returned from `request_payment`.
457#[derive(Clone, Debug)]
458pub struct PaymentRequestResult {
459    pub request_id: String,
460    pub request_proof: String,
461}
462
463// ---------------------------------------------------------------------------
464// Phase 3 — PeerPay payment types
465// ---------------------------------------------------------------------------
466
467/// Custom instructions embedded in a PeerPay transaction output.
468///
469/// Serializes to camelCase JSON for the TS wire format.
470/// `payee` is omitted when None (TS omits it when there's no explicit payee override).
471#[derive(Serialize, Deserialize, Clone, Debug)]
472#[serde(rename_all = "camelCase")]
473pub struct PaymentCustomInstructions {
474    pub derivation_prefix: String,
475    pub derivation_suffix: String,
476    #[serde(skip_serializing_if = "Option::is_none")]
477    pub payee: Option<String>,
478}
479
480/// A PeerPay payment token sent to a recipient's payment_inbox.
481///
482/// Serializes to camelCase JSON matching the TS PaymentToken wire format:
483/// - `transaction` is a number array (Vec<u8>) on the wire
484/// - `outputIndex` is omitted at creation time (None); defaulted to 0 at accept time
485#[derive(Serialize, Deserialize, Clone, Debug)]
486#[serde(rename_all = "camelCase")]
487pub struct PaymentToken {
488    pub custom_instructions: PaymentCustomInstructions,
489    /// Raw transaction bytes.
490    pub transaction: Vec<u8>,
491    pub amount: u64,
492    /// Only present after being set by the sender; defaults to 0 at accept time.
493    #[serde(skip_serializing_if = "Option::is_none")]
494    pub output_index: Option<u32>,
495}
496
497/// A parsed incoming payment from the payment_inbox.
498///
499/// Holds the decoded token plus routing metadata needed for accept/reject.
500/// NOT serialized — constructed internally from a ServerPeerMessage.
501#[derive(Clone, Debug)]
502pub struct IncomingPayment {
503    pub token: PaymentToken,
504    pub sender: String,
505    pub message_id: String,
506}
507
508#[cfg(test)]
509mod tests {
510    use super::*;
511
512    // -----------------------------------------------------------------------
513    // Phase 2 — permission type tests
514    // -----------------------------------------------------------------------
515
516    #[test]
517    fn set_permission_params_serializes_camel_case() {
518        let p = SetPermissionParams {
519            message_box: "payment_inbox".to_string(),
520            sender: None,
521            recipient_fee: 100,
522        };
523        let json = serde_json::to_string(&p).unwrap();
524        assert!(json.contains("\"messageBox\""), "messageBox field name");
525        assert!(json.contains("\"recipientFee\""), "recipientFee field name");
526        assert!(!json.contains("message_box"), "no snake_case leakage");
527        assert!(!json.contains("recipient_fee"), "no snake_case leakage");
528        assert!(!json.contains("sender"), "sender absent when None");
529    }
530
531    #[test]
532    fn set_permission_params_includes_sender_when_some() {
533        let p = SetPermissionParams {
534            message_box: "inbox".to_string(),
535            sender: Some("03abc".to_string()),
536            recipient_fee: 0,
537        };
538        let json = serde_json::to_string(&p).unwrap();
539        assert!(json.contains("\"sender\""), "sender present when Some");
540        assert!(json.contains("\"03abc\""), "sender value correct");
541    }
542
543    #[test]
544    fn message_box_permission_deserializes_camel_case() {
545        let raw = r#"{
546            "messageBox": "payment_inbox",
547            "recipientFee": 50,
548            "createdAt": "2024-01-01T00:00:00Z",
549            "updatedAt": "2024-01-02T00:00:00Z"
550        }"#;
551        let perm: MessageBoxPermission = serde_json::from_str(raw).unwrap();
552        assert_eq!(perm.message_box, "payment_inbox");
553        assert_eq!(perm.recipient_fee, 50);
554        assert_eq!(perm.created_at, "2024-01-01T00:00:00Z");
555        assert_eq!(perm.updated_at, "2024-01-02T00:00:00Z");
556    }
557
558    #[test]
559    fn message_box_permission_deserializes_snake_case() {
560        // The /permissions/list endpoint returns snake_case field names.
561        let raw = r#"{
562            "message_box": "payment_inbox",
563            "recipient_fee": 75,
564            "created_at": "2024-01-01T00:00:00Z",
565            "updated_at": "2024-01-02T00:00:00Z"
566        }"#;
567        let perm: MessageBoxPermission = serde_json::from_str(raw).unwrap();
568        assert_eq!(perm.message_box, "payment_inbox");
569        assert_eq!(perm.recipient_fee, 75);
570        assert_eq!(perm.created_at, "2024-01-01T00:00:00Z");
571        assert_eq!(perm.updated_at, "2024-01-02T00:00:00Z");
572    }
573
574    #[test]
575    fn message_box_permission_status_computes_correctly() {
576        let make = |fee: i64| MessageBoxPermission {
577            sender: None,
578            message_box: "inbox".to_string(),
579            recipient_fee: fee,
580            created_at: "2024-01-01".to_string(),
581            updated_at: "2024-01-01".to_string(),
582        };
583        assert_eq!(make(-1).status(), "blocked");
584        assert_eq!(make(0).status(), "always_allow");
585        assert_eq!(make(100).status(), "payment_required");
586        assert_eq!(make(1).status(), "payment_required");
587    }
588
589    #[test]
590    fn message_box_quote_can_be_constructed() {
591        // MessageBoxQuote is manually constructed — not deserialized from JSON.
592        let quote = MessageBoxQuote {
593            delivery_fee: 10,
594            recipient_fee: 50,
595            delivery_agent_identity_key: "03deadbeef".to_string(),
596        };
597        assert_eq!(quote.delivery_fee, 10);
598        assert_eq!(quote.recipient_fee, 50);
599        assert_eq!(quote.delivery_agent_identity_key, "03deadbeef");
600    }
601
602    // -----------------------------------------------------------------------
603    // Phase 3 — payment type tests
604    // -----------------------------------------------------------------------
605
606    #[test]
607    fn payment_custom_instructions_round_trip() {
608        let ci = PaymentCustomInstructions {
609            derivation_prefix: "pfx123".to_string(),
610            derivation_suffix: "sfx456".to_string(),
611            payee: Some("03abc".to_string()),
612        };
613        let json = serde_json::to_string(&ci).unwrap();
614        let back: PaymentCustomInstructions = serde_json::from_str(&json).unwrap();
615        assert_eq!(back.derivation_prefix, "pfx123");
616        assert_eq!(back.derivation_suffix, "sfx456");
617        assert_eq!(back.payee, Some("03abc".to_string()));
618    }
619
620    #[test]
621    fn payment_token_serializes_camel_case() {
622        let token = PaymentToken {
623            custom_instructions: PaymentCustomInstructions {
624                derivation_prefix: "pfx".to_string(),
625                derivation_suffix: "sfx".to_string(),
626                payee: Some("03recipient".to_string()),
627            },
628            transaction: vec![1, 2, 3],
629            amount: 1000,
630            output_index: Some(0),
631        };
632        let json = serde_json::to_string(&token).unwrap();
633        // Verify camelCase field names
634        assert!(json.contains("\"customInstructions\""), "customInstructions field name");
635        assert!(json.contains("\"derivationPrefix\""), "derivationPrefix field name");
636        assert!(json.contains("\"derivationSuffix\""), "derivationSuffix field name");
637        assert!(json.contains("\"payee\""), "payee present when Some");
638        assert!(json.contains("\"outputIndex\""), "outputIndex present when Some");
639        // No snake_case leakage
640        assert!(!json.contains("custom_instructions"), "no snake_case leakage");
641        assert!(!json.contains("derivation_prefix"), "no snake_case leakage");
642        assert!(!json.contains("output_index"), "no snake_case leakage");
643    }
644
645    #[test]
646    fn payment_token_no_output_index_by_default() {
647        let token = PaymentToken {
648            custom_instructions: PaymentCustomInstructions {
649                derivation_prefix: "pfx".to_string(),
650                derivation_suffix: "sfx".to_string(),
651                payee: None,
652            },
653            transaction: vec![0xab, 0xcd],
654            amount: 500,
655            output_index: None,
656        };
657        let json = serde_json::to_string(&token).unwrap();
658        // outputIndex must be absent when None
659        assert!(!json.contains("outputIndex"), "outputIndex absent when None");
660        // payee must be absent when None
661        assert!(!json.contains("payee"), "payee absent when None");
662    }
663
664    #[test]
665    fn incoming_payment_can_be_constructed() {
666        let token = PaymentToken {
667            custom_instructions: PaymentCustomInstructions {
668                derivation_prefix: "p".to_string(),
669                derivation_suffix: "s".to_string(),
670                payee: None,
671            },
672            transaction: vec![0x01],
673            amount: 2000,
674            output_index: None,
675        };
676        let incoming = IncomingPayment {
677            token: token.clone(),
678            sender: "03sender".to_string(),
679            message_id: "msg001".to_string(),
680        };
681        assert_eq!(incoming.sender, "03sender");
682        assert_eq!(incoming.message_id, "msg001");
683        assert_eq!(incoming.token.amount, 2000);
684    }
685
686    // -----------------------------------------------------------------------
687    // Phase 5 — overlay/device registration type tests
688    // -----------------------------------------------------------------------
689
690    #[test]
691    fn register_device_request_camel_case() {
692        let req = RegisterDeviceRequest {
693            fcm_token: "tok123".to_string(),
694            device_id: Some("dev-abc".to_string()),
695            platform: Some("android".to_string()),
696        };
697        let json = serde_json::to_string(&req).unwrap();
698        assert!(json.contains("\"fcmToken\""), "fcmToken field name");
699        assert!(json.contains("\"deviceId\""), "deviceId field name");
700        assert!(json.contains("\"platform\""), "platform field name");
701        assert!(!json.contains("fcm_token"), "no snake_case leakage");
702        assert!(!json.contains("device_id"), "no snake_case leakage");
703    }
704
705    #[test]
706    fn register_device_request_omits_optional_fields_when_none() {
707        let req = RegisterDeviceRequest {
708            fcm_token: "tok456".to_string(),
709            device_id: None,
710            platform: None,
711        };
712        let json = serde_json::to_string(&req).unwrap();
713        assert!(json.contains("\"fcmToken\""), "fcmToken present");
714        assert!(!json.contains("deviceId"), "deviceId absent when None");
715        assert!(!json.contains("platform"), "platform absent when None");
716    }
717
718    #[test]
719    fn registered_device_deserializes_camel_case() {
720        let raw = r#"{
721            "id": 42,
722            "deviceId": "dev-123",
723            "platform": "ios",
724            "fcmToken": "fcm-abc",
725            "active": true,
726            "createdAt": "2024-01-01T00:00:00Z",
727            "updatedAt": "2024-01-02T00:00:00Z",
728            "lastUsed": "2024-01-03T00:00:00Z"
729        }"#;
730        let dev: RegisteredDevice = serde_json::from_str(raw).unwrap();
731        assert_eq!(dev.id, Some(42));
732        assert_eq!(dev.device_id.as_deref(), Some("dev-123"));
733        assert_eq!(dev.platform.as_deref(), Some("ios"));
734        assert_eq!(dev.fcm_token, "fcm-abc");
735        assert_eq!(dev.active, Some(true));
736        assert_eq!(dev.created_at.as_deref(), Some("2024-01-01T00:00:00Z"));
737        assert_eq!(dev.last_used.as_deref(), Some("2024-01-03T00:00:00Z"));
738    }
739
740    // -----------------------------------------------------------------------
741    // Phase 1 — existing tests
742    // -----------------------------------------------------------------------
743
744    #[test]
745    fn send_message_params_serializes_camel_case() {
746        let p = SendMessageParams {
747            recipient: "03abc".to_string(),
748            message_box: "inbox".to_string(),
749            body: "hello".to_string(),
750            message_id: "deadbeef".to_string(),
751        };
752        let json = serde_json::to_string(&p).unwrap();
753        assert!(json.contains("\"messageBox\""), "messageBox field name");
754        assert!(json.contains("\"messageId\""), "messageId field name");
755        assert!(!json.contains("message_box"), "no snake_case leakage");
756        assert!(!json.contains("message_id"), "no snake_case leakage");
757    }
758
759    #[test]
760    fn acknowledge_params_serializes_camel_case() {
761        let p = AcknowledgeMessageParams {
762            message_ids: vec!["id1".to_string(), "id2".to_string()],
763        };
764        let json = serde_json::to_string(&p).unwrap();
765        assert!(json.contains("\"messageIds\""), "messageIds field name");
766        assert!(!json.contains("message_ids"), "no snake_case leakage");
767    }
768
769    #[test]
770    fn server_peer_message_tolerates_unknown_fields() {
771        // The server may add fields not in our struct — must NOT reject them.
772        let raw = r#"{
773            "messageId": "abc123",
774            "body": "hello",
775            "sender": "03xyz",
776            "created_at": "2024-01-01T00:00:00Z",
777            "updated_at": "2024-01-01T00:00:00Z",
778            "acknowledged": false,
779            "unknownField": "should be ignored",
780            "anotherExtra": 42
781        }"#;
782        let msg: ServerPeerMessage = serde_json::from_str(raw).unwrap();
783        assert_eq!(msg.message_id, "abc123");
784        assert_eq!(msg.body, "hello");
785        assert_eq!(msg.acknowledged, Some(false));
786    }
787}