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/// Parameters for listing messages from a specific inbox.
308#[derive(Serialize, Deserialize, Clone, Debug)]
309#[serde(rename_all = "camelCase")]
310pub struct ListMessagesParams {
311    pub message_box: String,
312}
313
314/// Parameters for acknowledging (marking as read) a set of messages.
315#[derive(Serialize, Deserialize, Clone, Debug)]
316#[serde(rename_all = "camelCase")]
317pub struct AcknowledgeMessageParams {
318    pub message_ids: Vec<String>,
319}
320
321/// A message as returned by the server's list endpoint.
322///
323/// Uses explicit `serde(rename)` on camelCase fields because the server mixes
324/// naming conventions: `messageId`, `sender` are camelCase but `created_at` /
325/// `updated_at` are snake_case. Do NOT use `deny_unknown_fields` — the server
326/// may add new fields at any time.
327#[derive(Serialize, Deserialize, Clone, Debug)]
328pub struct ServerPeerMessage {
329    #[serde(rename = "messageId")]
330    pub message_id: String,
331    pub body: String,
332    pub sender: String,
333    #[serde(alias = "createdAt")]
334    pub created_at: String,
335    #[serde(alias = "updatedAt")]
336    pub updated_at: String,
337    #[serde(skip_serializing_if = "Option::is_none")]
338    pub acknowledged: Option<bool>,
339    /// `true` iff `body` was produced by a genuine authenticated (AEAD) decrypt
340    /// of an encrypted envelope against `sender`'s key — set by
341    /// [`crate::client::MessageBoxClient::list_messages_lite`] via the typed
342    /// decrypt path. ADDITIVE + `#[serde(default)]`: never sent on the wire by the
343    /// server (it is `false` after deserialization), so this does not break wire
344    /// compatibility or any existing consumer (e.g. peerpay). Consumers requiring
345    /// sender provenance (the MPC transport) reject bodies with this `false`.
346    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
347    pub authenticated_decrypt: bool,
348}
349
350/// A live-delivered peer message paired with its authenticated-decrypt provenance.
351///
352/// Client-owned sibling of `bsv::remittance::types::PeerMessage` (which is an
353/// upstream SDK type we cannot extend). Delivered by
354/// [`crate::client::MessageBoxClient::listen_for_live_messages_typed`] so that
355/// provenance-requiring consumers (the MPC transport) can fail-closed on bodies
356/// that did not AEAD-decrypt against `sender`. The legacy
357/// [`crate::client::MessageBoxClient::listen_for_live_messages`] continues to
358/// deliver a bare `PeerMessage` (this flag dropped) for existing consumers.
359#[derive(Clone, Debug)]
360pub struct AuthenticatedPeerMessage {
361    pub message_id: String,
362    pub sender: String,
363    pub recipient: String,
364    pub message_box: String,
365    pub body: String,
366    /// `true` iff `body` came from a genuine authenticated (AEAD) decrypt of an
367    /// encrypted envelope against `sender`'s key.
368    pub authenticated_decrypt: bool,
369}
370
371/// Response from the `/listMessages` endpoint.
372#[derive(Serialize, Deserialize, Clone, Debug)]
373#[serde(rename_all = "camelCase")]
374pub struct ListMessagesResponse {
375    pub status: String,
376    pub messages: Vec<ServerPeerMessage>,
377}
378
379/// Response from the `/sendMessage` endpoint.
380#[derive(Serialize, Deserialize, Clone, Debug)]
381#[serde(rename_all = "camelCase")]
382pub struct SendMessageResponse {
383    pub status: String,
384    /// The server-assigned message ID (may be absent on some server versions).
385    #[serde(skip_serializing_if = "Option::is_none")]
386    pub message_id: Option<String>,
387}
388
389// ---------------------------------------------------------------------------
390// Phase 4 — WebSocket live messaging wire types
391// ---------------------------------------------------------------------------
392
393/// Payload for the sendMessage WebSocket event.
394///
395/// Serialized as JSON and passed to socket.emit("sendMessage", ...).
396/// Wire format: {"roomId": "recipient-inbox", "message": {"messageId": "...", ...}}
397#[derive(Serialize, Deserialize, Clone, Debug)]
398#[serde(rename_all = "camelCase")]
399pub struct WsSendMessageData {
400    pub room_id: String,
401    pub message: WsSendMessagePayload,
402}
403
404/// The message object inside a sendMessage event.
405#[derive(Serialize, Deserialize, Clone, Debug)]
406#[serde(rename_all = "camelCase")]
407pub struct WsSendMessagePayload {
408    pub message_id: String,
409    pub recipient: String,
410    pub body: String,
411}
412
413// ---------------------------------------------------------------------------
414// Phase 7 — Payment Request types (PeerPay Request System)
415// ---------------------------------------------------------------------------
416
417/// Message box names for the payment request system.
418pub const PAYMENT_REQUESTS_MESSAGEBOX: &str = "payment_requests";
419pub const PAYMENT_REQUEST_RESPONSES_MESSAGEBOX: &str = "payment_request_responses";
420
421/// Default limits for filtering incoming payment requests.
422pub const DEFAULT_PAYMENT_REQUEST_MIN_AMOUNT: u64 = 1000;
423pub const DEFAULT_PAYMENT_REQUEST_MAX_AMOUNT: u64 = 10_000_000;
424
425/// A payment request message sent to a recipient's `payment_requests` inbox.
426///
427/// Discriminated union: if `cancelled` is `Some(true)`, this is a cancellation
428/// message and `amount`/`description`/`expires_at` will be absent.
429#[derive(Serialize, Deserialize, Clone, Debug)]
430#[serde(rename_all = "camelCase")]
431pub struct PaymentRequestMessage {
432    pub request_id: String,
433    pub sender_identity_key: String,
434    pub request_proof: String,
435    #[serde(skip_serializing_if = "Option::is_none")]
436    pub amount: Option<u64>,
437    #[serde(skip_serializing_if = "Option::is_none")]
438    pub description: Option<String>,
439    #[serde(skip_serializing_if = "Option::is_none")]
440    pub expires_at: Option<u64>,
441    #[serde(skip_serializing_if = "Option::is_none")]
442    pub cancelled: Option<bool>,
443}
444
445/// Response to a payment request, sent to the requester's
446/// `payment_request_responses` inbox.
447#[derive(Serialize, Deserialize, Clone, Debug)]
448#[serde(rename_all = "camelCase")]
449pub struct PaymentRequestResponse {
450    pub request_id: String,
451    pub status: String, // "paid" | "declined"
452    #[serde(skip_serializing_if = "Option::is_none")]
453    pub note: Option<String>,
454    #[serde(skip_serializing_if = "Option::is_none")]
455    pub amount_paid: Option<u64>,
456}
457
458/// A validated incoming payment request (after filtering and HMAC verification).
459#[derive(Clone, Debug)]
460pub struct IncomingPaymentRequest {
461    pub message_id: String,
462    pub sender: String,
463    pub request_id: String,
464    pub amount: u64,
465    pub description: String,
466    pub expires_at: u64,
467}
468
469/// Optional limits for filtering incoming payment requests.
470#[derive(Clone, Debug)]
471pub struct PaymentRequestLimits {
472    pub min_amount: u64,
473    pub max_amount: u64,
474}
475
476impl Default for PaymentRequestLimits {
477    fn default() -> Self {
478        Self {
479            min_amount: DEFAULT_PAYMENT_REQUEST_MIN_AMOUNT,
480            max_amount: DEFAULT_PAYMENT_REQUEST_MAX_AMOUNT,
481        }
482    }
483}
484
485/// Result returned from `request_payment`.
486#[derive(Clone, Debug)]
487pub struct PaymentRequestResult {
488    pub request_id: String,
489    pub request_proof: String,
490}
491
492// ---------------------------------------------------------------------------
493// Phase 3 — PeerPay payment types
494// ---------------------------------------------------------------------------
495
496/// Custom instructions embedded in a PeerPay transaction output.
497///
498/// Serializes to camelCase JSON for the TS wire format.
499/// `payee` is omitted when None (TS omits it when there's no explicit payee override).
500#[derive(Serialize, Deserialize, Clone, Debug)]
501#[serde(rename_all = "camelCase")]
502pub struct PaymentCustomInstructions {
503    pub derivation_prefix: String,
504    pub derivation_suffix: String,
505    #[serde(skip_serializing_if = "Option::is_none")]
506    pub payee: Option<String>,
507}
508
509/// A PeerPay payment token sent to a recipient's payment_inbox.
510///
511/// Serializes to camelCase JSON matching the TS PaymentToken wire format:
512/// - `transaction` is a number array (Vec<u8>) on the wire
513/// - `outputIndex` is omitted at creation time (None); defaulted to 0 at accept time
514#[derive(Serialize, Deserialize, Clone, Debug)]
515#[serde(rename_all = "camelCase")]
516pub struct PaymentToken {
517    pub custom_instructions: PaymentCustomInstructions,
518    /// Raw transaction bytes.
519    pub transaction: Vec<u8>,
520    pub amount: u64,
521    /// Only present after being set by the sender; defaults to 0 at accept time.
522    #[serde(skip_serializing_if = "Option::is_none")]
523    pub output_index: Option<u32>,
524}
525
526/// A parsed incoming payment from the payment_inbox.
527///
528/// Holds the decoded token plus routing metadata needed for accept/reject.
529/// NOT serialized — constructed internally from a ServerPeerMessage.
530#[derive(Clone, Debug)]
531pub struct IncomingPayment {
532    pub token: PaymentToken,
533    pub sender: String,
534    pub message_id: String,
535}
536
537#[cfg(test)]
538mod tests {
539    use super::*;
540
541    // -----------------------------------------------------------------------
542    // Phase 2 — permission type tests
543    // -----------------------------------------------------------------------
544
545    #[test]
546    fn set_permission_params_serializes_camel_case() {
547        let p = SetPermissionParams {
548            message_box: "payment_inbox".to_string(),
549            sender: None,
550            recipient_fee: 100,
551        };
552        let json = serde_json::to_string(&p).unwrap();
553        assert!(json.contains("\"messageBox\""), "messageBox field name");
554        assert!(json.contains("\"recipientFee\""), "recipientFee field name");
555        assert!(!json.contains("message_box"), "no snake_case leakage");
556        assert!(!json.contains("recipient_fee"), "no snake_case leakage");
557        assert!(!json.contains("sender"), "sender absent when None");
558    }
559
560    #[test]
561    fn set_permission_params_includes_sender_when_some() {
562        let p = SetPermissionParams {
563            message_box: "inbox".to_string(),
564            sender: Some("03abc".to_string()),
565            recipient_fee: 0,
566        };
567        let json = serde_json::to_string(&p).unwrap();
568        assert!(json.contains("\"sender\""), "sender present when Some");
569        assert!(json.contains("\"03abc\""), "sender value correct");
570    }
571
572    #[test]
573    fn message_box_permission_deserializes_camel_case() {
574        let raw = r#"{
575            "messageBox": "payment_inbox",
576            "recipientFee": 50,
577            "createdAt": "2024-01-01T00:00:00Z",
578            "updatedAt": "2024-01-02T00:00:00Z"
579        }"#;
580        let perm: MessageBoxPermission = serde_json::from_str(raw).unwrap();
581        assert_eq!(perm.message_box, "payment_inbox");
582        assert_eq!(perm.recipient_fee, 50);
583        assert_eq!(perm.created_at, "2024-01-01T00:00:00Z");
584        assert_eq!(perm.updated_at, "2024-01-02T00:00:00Z");
585    }
586
587    #[test]
588    fn message_box_permission_deserializes_snake_case() {
589        // The /permissions/list endpoint returns snake_case field names.
590        let raw = r#"{
591            "message_box": "payment_inbox",
592            "recipient_fee": 75,
593            "created_at": "2024-01-01T00:00:00Z",
594            "updated_at": "2024-01-02T00:00:00Z"
595        }"#;
596        let perm: MessageBoxPermission = serde_json::from_str(raw).unwrap();
597        assert_eq!(perm.message_box, "payment_inbox");
598        assert_eq!(perm.recipient_fee, 75);
599        assert_eq!(perm.created_at, "2024-01-01T00:00:00Z");
600        assert_eq!(perm.updated_at, "2024-01-02T00:00:00Z");
601    }
602
603    #[test]
604    fn message_box_permission_status_computes_correctly() {
605        let make = |fee: i64| MessageBoxPermission {
606            sender: None,
607            message_box: "inbox".to_string(),
608            recipient_fee: fee,
609            created_at: "2024-01-01".to_string(),
610            updated_at: "2024-01-01".to_string(),
611        };
612        assert_eq!(make(-1).status(), "blocked");
613        assert_eq!(make(0).status(), "always_allow");
614        assert_eq!(make(100).status(), "payment_required");
615        assert_eq!(make(1).status(), "payment_required");
616    }
617
618    #[test]
619    fn message_box_quote_can_be_constructed() {
620        // MessageBoxQuote is manually constructed — not deserialized from JSON.
621        let quote = MessageBoxQuote {
622            delivery_fee: 10,
623            recipient_fee: 50,
624            delivery_agent_identity_key: "03deadbeef".to_string(),
625        };
626        assert_eq!(quote.delivery_fee, 10);
627        assert_eq!(quote.recipient_fee, 50);
628        assert_eq!(quote.delivery_agent_identity_key, "03deadbeef");
629    }
630
631    // -----------------------------------------------------------------------
632    // Phase 3 — payment type tests
633    // -----------------------------------------------------------------------
634
635    #[test]
636    fn payment_custom_instructions_round_trip() {
637        let ci = PaymentCustomInstructions {
638            derivation_prefix: "pfx123".to_string(),
639            derivation_suffix: "sfx456".to_string(),
640            payee: Some("03abc".to_string()),
641        };
642        let json = serde_json::to_string(&ci).unwrap();
643        let back: PaymentCustomInstructions = serde_json::from_str(&json).unwrap();
644        assert_eq!(back.derivation_prefix, "pfx123");
645        assert_eq!(back.derivation_suffix, "sfx456");
646        assert_eq!(back.payee, Some("03abc".to_string()));
647    }
648
649    #[test]
650    fn payment_token_serializes_camel_case() {
651        let token = PaymentToken {
652            custom_instructions: PaymentCustomInstructions {
653                derivation_prefix: "pfx".to_string(),
654                derivation_suffix: "sfx".to_string(),
655                payee: Some("03recipient".to_string()),
656            },
657            transaction: vec![1, 2, 3],
658            amount: 1000,
659            output_index: Some(0),
660        };
661        let json = serde_json::to_string(&token).unwrap();
662        // Verify camelCase field names
663        assert!(
664            json.contains("\"customInstructions\""),
665            "customInstructions field name"
666        );
667        assert!(
668            json.contains("\"derivationPrefix\""),
669            "derivationPrefix field name"
670        );
671        assert!(
672            json.contains("\"derivationSuffix\""),
673            "derivationSuffix field name"
674        );
675        assert!(json.contains("\"payee\""), "payee present when Some");
676        assert!(
677            json.contains("\"outputIndex\""),
678            "outputIndex present when Some"
679        );
680        // No snake_case leakage
681        assert!(
682            !json.contains("custom_instructions"),
683            "no snake_case leakage"
684        );
685        assert!(!json.contains("derivation_prefix"), "no snake_case leakage");
686        assert!(!json.contains("output_index"), "no snake_case leakage");
687    }
688
689    #[test]
690    fn payment_token_no_output_index_by_default() {
691        let token = PaymentToken {
692            custom_instructions: PaymentCustomInstructions {
693                derivation_prefix: "pfx".to_string(),
694                derivation_suffix: "sfx".to_string(),
695                payee: None,
696            },
697            transaction: vec![0xab, 0xcd],
698            amount: 500,
699            output_index: None,
700        };
701        let json = serde_json::to_string(&token).unwrap();
702        // outputIndex must be absent when None
703        assert!(
704            !json.contains("outputIndex"),
705            "outputIndex absent when None"
706        );
707        // payee must be absent when None
708        assert!(!json.contains("payee"), "payee absent when None");
709    }
710
711    #[test]
712    fn incoming_payment_can_be_constructed() {
713        let token = PaymentToken {
714            custom_instructions: PaymentCustomInstructions {
715                derivation_prefix: "p".to_string(),
716                derivation_suffix: "s".to_string(),
717                payee: None,
718            },
719            transaction: vec![0x01],
720            amount: 2000,
721            output_index: None,
722        };
723        let incoming = IncomingPayment {
724            token: token.clone(),
725            sender: "03sender".to_string(),
726            message_id: "msg001".to_string(),
727        };
728        assert_eq!(incoming.sender, "03sender");
729        assert_eq!(incoming.message_id, "msg001");
730        assert_eq!(incoming.token.amount, 2000);
731    }
732
733    // -----------------------------------------------------------------------
734    // Phase 5 — overlay/device registration type tests
735    // -----------------------------------------------------------------------
736
737    #[test]
738    fn register_device_request_camel_case() {
739        let req = RegisterDeviceRequest {
740            fcm_token: "tok123".to_string(),
741            device_id: Some("dev-abc".to_string()),
742            platform: Some("android".to_string()),
743        };
744        let json = serde_json::to_string(&req).unwrap();
745        assert!(json.contains("\"fcmToken\""), "fcmToken field name");
746        assert!(json.contains("\"deviceId\""), "deviceId field name");
747        assert!(json.contains("\"platform\""), "platform field name");
748        assert!(!json.contains("fcm_token"), "no snake_case leakage");
749        assert!(!json.contains("device_id"), "no snake_case leakage");
750    }
751
752    #[test]
753    fn register_device_request_omits_optional_fields_when_none() {
754        let req = RegisterDeviceRequest {
755            fcm_token: "tok456".to_string(),
756            device_id: None,
757            platform: None,
758        };
759        let json = serde_json::to_string(&req).unwrap();
760        assert!(json.contains("\"fcmToken\""), "fcmToken present");
761        assert!(!json.contains("deviceId"), "deviceId absent when None");
762        assert!(!json.contains("platform"), "platform absent when None");
763    }
764
765    #[test]
766    fn registered_device_deserializes_camel_case() {
767        let raw = r#"{
768            "id": 42,
769            "deviceId": "dev-123",
770            "platform": "ios",
771            "fcmToken": "fcm-abc",
772            "active": true,
773            "createdAt": "2024-01-01T00:00:00Z",
774            "updatedAt": "2024-01-02T00:00:00Z",
775            "lastUsed": "2024-01-03T00:00:00Z"
776        }"#;
777        let dev: RegisteredDevice = serde_json::from_str(raw).unwrap();
778        assert_eq!(dev.id, Some(42));
779        assert_eq!(dev.device_id.as_deref(), Some("dev-123"));
780        assert_eq!(dev.platform.as_deref(), Some("ios"));
781        assert_eq!(dev.fcm_token, "fcm-abc");
782        assert_eq!(dev.active, Some(true));
783        assert_eq!(dev.created_at.as_deref(), Some("2024-01-01T00:00:00Z"));
784        assert_eq!(dev.last_used.as_deref(), Some("2024-01-03T00:00:00Z"));
785    }
786
787    // -----------------------------------------------------------------------
788    // Phase 1 — existing tests
789    // -----------------------------------------------------------------------
790
791    #[test]
792    fn send_message_params_serializes_camel_case() {
793        let p = SendMessageParams {
794            recipient: "03abc".to_string(),
795            message_box: "inbox".to_string(),
796            body: "hello".to_string(),
797            message_id: "deadbeef".to_string(),
798        };
799        let json = serde_json::to_string(&p).unwrap();
800        assert!(json.contains("\"messageBox\""), "messageBox field name");
801        assert!(json.contains("\"messageId\""), "messageId field name");
802        assert!(!json.contains("message_box"), "no snake_case leakage");
803        assert!(!json.contains("message_id"), "no snake_case leakage");
804    }
805
806    #[test]
807    fn acknowledge_params_serializes_camel_case() {
808        let p = AcknowledgeMessageParams {
809            message_ids: vec!["id1".to_string(), "id2".to_string()],
810        };
811        let json = serde_json::to_string(&p).unwrap();
812        assert!(json.contains("\"messageIds\""), "messageIds field name");
813        assert!(!json.contains("message_ids"), "no snake_case leakage");
814    }
815
816    #[test]
817    fn server_peer_message_tolerates_unknown_fields() {
818        // The server may add fields not in our struct — must NOT reject them.
819        let raw = r#"{
820            "messageId": "abc123",
821            "body": "hello",
822            "sender": "03xyz",
823            "created_at": "2024-01-01T00:00:00Z",
824            "updated_at": "2024-01-01T00:00:00Z",
825            "acknowledged": false,
826            "unknownField": "should be ignored",
827            "anotherExtra": 42
828        }"#;
829        let msg: ServerPeerMessage = serde_json::from_str(raw).unwrap();
830        assert_eq!(msg.message_id, "abc123");
831        assert_eq!(msg.body, "hello");
832        assert_eq!(msg.acknowledged, Some(false));
833    }
834}