1use 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#[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#[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#[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#[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}