Skip to main content

maincopy_shared/
profile_api.rs

1//! Versioned wire contracts for profile-backed Lightning tip settings.
2
3use serde::{Deserialize, Deserializer, Serialize, de::Error as _};
4use time::{OffsetDateTime, UtcOffset};
5
6use crate::{
7    auth::UserId,
8    profile::{LightningAddress, ProfileDisplayName, ProfileVersion},
9};
10
11pub const CURRENT_USER_PROFILE_PATH: &str = "/api/admin/v1/profile";
12pub const ACTIVE_TIP_RECIPIENT_PATH: &str = "/api/admin/v1/lightning/tip-recipient";
13
14/// The authenticated user's mutable public profile.
15#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
16#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
17pub struct UserProfileResponse {
18    pub user_id: UserId,
19    pub display_name: Option<ProfileDisplayName>,
20    pub lightning_address: Option<LightningAddress>,
21    pub tips_enabled: bool,
22    pub version: ProfileVersion,
23    #[serde(
24        serialize_with = "time::serde::rfc3339::serialize",
25        deserialize_with = "deserialize_utc_timestamp"
26    )]
27    #[cfg_attr(feature = "schema", schema(value_type = String, format = DateTime))]
28    pub updated_at: OffsetDateTime,
29}
30
31/// Creates or replaces the authenticated user's mutable public profile.
32///
33/// A missing expected version is a create-only precondition. A positive version
34/// is an update-only compare-and-swap precondition.
35#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
36#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
37#[serde(deny_unknown_fields)]
38pub struct UpdateUserProfileRequest {
39    pub display_name: Option<ProfileDisplayName>,
40    pub lightning_address: Option<LightningAddress>,
41    pub tips_enabled: bool,
42    #[serde(default)]
43    pub expected_version: Option<ProfileVersion>,
44}
45
46/// The versioned site setting selecting the only active tip recipient.
47#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
48#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
49pub struct ActiveTipRecipientResponse {
50    pub user_id: Option<UserId>,
51    pub version: ProfileVersion,
52    #[serde(
53        serialize_with = "time::serde::rfc3339::serialize",
54        deserialize_with = "deserialize_utc_timestamp"
55    )]
56    #[cfg_attr(feature = "schema", schema(value_type = String, format = DateTime))]
57    pub updated_at: OffsetDateTime,
58}
59
60/// Selects or clears the active site tip recipient at one exact setting version.
61#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
62#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
63#[serde(deny_unknown_fields)]
64pub struct PutActiveTipRecipientRequest {
65    pub user_id: Option<UserId>,
66    pub expected_version: ProfileVersion,
67}
68
69fn deserialize_utc_timestamp<'de, DeserializerType>(
70    deserializer: DeserializerType,
71) -> Result<OffsetDateTime, DeserializerType::Error>
72where
73    DeserializerType: Deserializer<'de>,
74{
75    let timestamp = time::serde::rfc3339::deserialize(deserializer)?;
76    if timestamp.offset() == UtcOffset::UTC {
77        Ok(timestamp)
78    } else {
79        Err(DeserializerType::Error::custom(
80            "updated_at must use the UTC offset",
81        ))
82    }
83}
84
85#[cfg(test)]
86mod tests {
87    use serde_json::json;
88    use uuid::Uuid;
89
90    use super::*;
91
92    fn user_id() -> UserId {
93        UserId::from_uuid(Uuid::parse_str("123e4567-e89b-42d3-a456-426614174000").unwrap())
94    }
95
96    fn updated_at() -> OffsetDateTime {
97        OffsetDateTime::from_unix_timestamp(1_700_000_000).unwrap()
98    }
99
100    fn version(value: u64) -> ProfileVersion {
101        ProfileVersion::new(value).unwrap()
102    }
103
104    #[test]
105    fn profile_and_recipient_paths_are_versioned_admin_resources() {
106        assert_eq!(CURRENT_USER_PROFILE_PATH, "/api/admin/v1/profile");
107        assert_eq!(
108            ACTIVE_TIP_RECIPIENT_PATH,
109            "/api/admin/v1/lightning/tip-recipient"
110        );
111    }
112
113    #[test]
114    fn user_profile_response_has_a_stable_bidirectional_wire_contract() {
115        let response = UserProfileResponse {
116            user_id: user_id(),
117            display_name: Some(ProfileDisplayName::parse("Alice Writer").unwrap()),
118            lightning_address: Some(LightningAddress::parse("alice@example.com").unwrap()),
119            tips_enabled: true,
120            version: version(7),
121            updated_at: updated_at(),
122        };
123
124        let value = serde_json::to_value(&response).unwrap();
125        assert_eq!(
126            value,
127            json!({
128                "user_id": "123e4567-e89b-42d3-a456-426614174000",
129                "display_name": "Alice Writer",
130                "lightning_address": "alice@example.com",
131                "tips_enabled": true,
132                "version": 7,
133                "updated_at": "2023-11-14T22:13:20Z"
134            })
135        );
136        assert_eq!(
137            serde_json::from_value::<UserProfileResponse>(value).unwrap(),
138            response
139        );
140    }
141
142    #[test]
143    fn response_readers_ignore_fields_added_by_a_newer_server() {
144        let profile = serde_json::from_value::<UserProfileResponse>(json!({
145            "user_id": "123e4567-e89b-42d3-a456-426614174000",
146            "display_name": "Alice Writer",
147            "lightning_address": "alice@example.com",
148            "tips_enabled": true,
149            "version": 7,
150            "updated_at": "2023-11-14T22:13:20Z",
151            "future_profile_field": {"version": 2}
152        }))
153        .unwrap();
154        assert_eq!(profile.version, version(7));
155
156        let recipient = serde_json::from_value::<ActiveTipRecipientResponse>(json!({
157            "user_id": null,
158            "version": 3,
159            "updated_at": "2023-11-14T22:13:20Z",
160            "future_recipient_field": true
161        }))
162        .unwrap();
163        assert_eq!(recipient.version, version(3));
164    }
165
166    #[test]
167    fn profile_update_request_supports_create_only_and_cleared_profile_fields() {
168        let value = json!({
169            "display_name": null,
170            "lightning_address": null,
171            "tips_enabled": false,
172            "expected_version": null
173        });
174        let request = serde_json::from_value::<UpdateUserProfileRequest>(value.clone()).unwrap();
175
176        assert_eq!(
177            request,
178            UpdateUserProfileRequest {
179                display_name: None,
180                lightning_address: None,
181                tips_enabled: false,
182                expected_version: None,
183            }
184        );
185        assert_eq!(serde_json::to_value(request).unwrap(), value);
186
187        let omitted = serde_json::from_value::<UpdateUserProfileRequest>(json!({
188            "display_name": null,
189            "lightning_address": null,
190            "tips_enabled": false
191        }))
192        .unwrap();
193        assert_eq!(omitted.expected_version, None);
194    }
195
196    #[test]
197    fn active_recipient_contract_selects_or_clears_one_typed_user() {
198        let selected = ActiveTipRecipientResponse {
199            user_id: Some(user_id()),
200            version: version(3),
201            updated_at: updated_at(),
202        };
203        let selected_value = serde_json::to_value(&selected).unwrap();
204        assert_eq!(
205            selected_value,
206            json!({
207                "user_id": "123e4567-e89b-42d3-a456-426614174000",
208                "version": 3,
209                "updated_at": "2023-11-14T22:13:20Z"
210            })
211        );
212        assert_eq!(
213            serde_json::from_value::<ActiveTipRecipientResponse>(selected_value).unwrap(),
214            selected
215        );
216
217        let cleared = PutActiveTipRecipientRequest {
218            user_id: None,
219            expected_version: version(3),
220        };
221        assert_eq!(
222            serde_json::to_value(cleared).unwrap(),
223            json!({"user_id": null, "expected_version": 3})
224        );
225    }
226
227    #[test]
228    fn contracts_reject_unknown_fields_and_invalid_nested_values() {
229        assert!(
230            serde_json::from_value::<UpdateUserProfileRequest>(json!({
231                "display_name": "Alice",
232                "lightning_address": "alice@example.com",
233                "tips_enabled": true,
234                "expected_version": 1,
235                "invoice": "not accepted"
236            }))
237            .is_err()
238        );
239        assert!(
240            serde_json::from_value::<UpdateUserProfileRequest>(json!({
241                "display_name": "Alice",
242                "lightning_address": "Alice@example.com",
243                "tips_enabled": true,
244                "expected_version": 1
245            }))
246            .is_err()
247        );
248        assert!(
249            serde_json::from_value::<PutActiveTipRecipientRequest>(json!({
250                "user_id": "not-a-uuid",
251                "expected_version": 1
252            }))
253            .is_err()
254        );
255    }
256
257    #[test]
258    fn zero_versions_and_non_utc_timestamps_are_rejected() {
259        assert!(
260            serde_json::from_value::<UpdateUserProfileRequest>(json!({
261                "display_name": null,
262                "lightning_address": null,
263                "tips_enabled": false,
264                "expected_version": 0
265            }))
266            .is_err()
267        );
268        assert!(
269            serde_json::from_value::<PutActiveTipRecipientRequest>(json!({
270                "user_id": null,
271                "expected_version": 0
272            }))
273            .is_err()
274        );
275        assert!(
276            serde_json::from_value::<ActiveTipRecipientResponse>(json!({
277                "user_id": null,
278                "version": 1,
279                "updated_at": "2023-11-14T17:13:20-05:00"
280            }))
281            .is_err()
282        );
283        assert!(
284            serde_json::from_value::<PutActiveTipRecipientRequest>(json!({
285                "user_id": null,
286                "expected_version": 9223372036854775808_u64
287            }))
288            .is_err()
289        );
290    }
291}