job_result.rs (7981B)
1 mod common; 2 #[path = "../src/test_fixtures.rs"] 3 mod test_fixtures; 4 5 use radroots_event::envelope::kind::{KIND_JOB_REQUEST_MIN, KIND_JOB_RESULT_MIN}; 6 use radroots_event::social::job::{JobInputType, JobPaymentRequest}; 7 use radroots_event::social::job_request::JobInput; 8 use radroots_event::social::job_result::JobResult; 9 use radroots_event_codec::decode::job::JobParseError; 10 use radroots_event_codec::decode::job::result::{job_result_from_tags, parsed_from_event}; 11 use radroots_event_codec::encode::job::JobEncodeError; 12 use radroots_event_codec::encode::job::result::to_wire_parts; 13 use test_fixtures::{APP_PRIMARY_HTTPS, RELAY_PRIMARY_WSS, RELAY_SECONDARY_WSS}; 14 15 fn sample_result() -> JobResult { 16 JobResult { 17 kind: u16::try_from(KIND_JOB_RESULT_MIN + 1).expect("result kind must fit NIP-01"), 18 request_event: common::event_ptr("req", Some(RELAY_PRIMARY_WSS)), 19 request_json: Some("{\"foo\":\"bar\"}".to_string()), 20 inputs: vec![JobInput { 21 data: APP_PRIMARY_HTTPS.to_string(), 22 input_type: JobInputType::Url, 23 relay: None, 24 marker: None, 25 }], 26 customer_pubkey: Some("customer".to_string()), 27 payment: Some(JobPaymentRequest { 28 amount_sat: 50, 29 bolt11: Some("bolt".to_string()), 30 }), 31 content: Some("payload".to_string()), 32 encrypted: false, 33 } 34 } 35 36 #[test] 37 fn job_result_roundtrip_from_tags() { 38 let res = sample_result(); 39 let content = res.content.clone().unwrap(); 40 let parts = to_wire_parts(&res, &content).unwrap(); 41 42 let decoded = job_result_from_tags(parts.kind, &parts.tags, &content).unwrap(); 43 assert_eq!(decoded, res); 44 } 45 46 #[test] 47 fn job_result_from_tags_rejects_kind_overflow() { 48 let kind = u32::from(u16::MAX) + 1; 49 assert!(matches!( 50 job_result_from_tags(kind, &[], ""), 51 Err(JobParseError::KindOutOfRange(actual)) if actual == kind 52 )); 53 } 54 55 #[test] 56 fn job_result_roundtrip_with_empty_content_sets_none() { 57 let res = sample_result(); 58 let parts = to_wire_parts(&res, "").unwrap(); 59 let decoded = job_result_from_tags(parts.kind, &parts.tags, "").unwrap(); 60 assert!(decoded.content.is_none()); 61 } 62 63 #[test] 64 fn job_result_roundtrip_preserves_input_relay_and_marker() { 65 let mut res = sample_result(); 66 res.inputs = vec![JobInput { 67 data: "note1payload".to_string(), 68 input_type: JobInputType::Event, 69 relay: Some(RELAY_SECONDARY_WSS.to_string()), 70 marker: Some("root".to_string()), 71 }]; 72 let content = res.content.clone().unwrap(); 73 let parts = to_wire_parts(&res, &content).unwrap(); 74 let decoded = job_result_from_tags(parts.kind, &parts.tags, &content).unwrap(); 75 assert_eq!(decoded, res); 76 } 77 78 #[test] 79 fn job_result_requires_valid_kind() { 80 let mut res = sample_result(); 81 res.kind = u16::try_from(KIND_JOB_REQUEST_MIN).expect("request kind must fit NIP-01"); 82 83 let err = to_wire_parts(&res, "payload").unwrap_err(); 84 assert!(matches!( 85 err, 86 JobEncodeError::InvalidKind(KIND_JOB_REQUEST_MIN) 87 )); 88 } 89 90 #[test] 91 fn job_result_encrypted_adds_flag_and_rejects_inputs() { 92 let mut encrypted = sample_result(); 93 encrypted.encrypted = true; 94 encrypted.inputs.clear(); 95 let content = encrypted.content.clone().unwrap(); 96 let parts = to_wire_parts(&encrypted, &content).unwrap(); 97 assert!( 98 parts 99 .tags 100 .iter() 101 .any(|tag| tag.first().map(|v| v.as_str()) == Some("encrypted")) 102 ); 103 assert!( 104 !parts 105 .tags 106 .iter() 107 .any(|tag| tag.first().map(|v| v.as_str()) == Some("i")) 108 ); 109 110 let mut invalid = sample_result(); 111 invalid.encrypted = true; 112 let err = to_wire_parts(&invalid, "payload").unwrap_err(); 113 assert!(matches!( 114 err, 115 JobEncodeError::EmptyRequiredField("inputs-when-encrypted") 116 )); 117 } 118 119 #[test] 120 fn job_result_build_tags_supports_minimal_optional_fields() { 121 let mut res = sample_result(); 122 res.request_json = None; 123 res.inputs.clear(); 124 res.customer_pubkey = None; 125 res.payment = None; 126 let parts = to_wire_parts(&res, "payload").unwrap(); 127 assert!( 128 parts 129 .tags 130 .iter() 131 .any(|tag| tag.first().map(|v| v.as_str()) == Some("e")) 132 ); 133 assert!( 134 !parts 135 .tags 136 .iter() 137 .any(|tag| tag.first().map(|v| v.as_str()) == Some("request")) 138 ); 139 assert!( 140 !parts 141 .tags 142 .iter() 143 .any(|tag| tag.first().map(|v| v.as_str()) == Some("i")) 144 ); 145 assert!( 146 !parts 147 .tags 148 .iter() 149 .any(|tag| tag.first().map(|v| v.as_str()) == Some("p")) 150 ); 151 assert!( 152 !parts 153 .tags 154 .iter() 155 .any(|tag| tag.first().map(|v| v.as_str()) == Some("amount")) 156 ); 157 } 158 159 #[test] 160 fn job_result_build_tags_omits_request_relay_when_absent() { 161 let mut res = sample_result(); 162 res.request_event.relays = None; 163 let parts = to_wire_parts(&res, "payload").unwrap(); 164 let request = parts 165 .tags 166 .iter() 167 .find(|tag| tag.first().map(|v| v.as_str()) == Some("e")) 168 .expect("request tag"); 169 assert_eq!(request.len(), 2); 170 } 171 172 #[test] 173 fn job_result_requires_request_event_tag() { 174 let tags = vec![vec!["p".to_string(), "customer".to_string()]]; 175 let err = job_result_from_tags(KIND_JOB_RESULT_MIN + 1, &tags, "payload").unwrap_err(); 176 assert!(matches!(err, JobParseError::MissingTag("e"))); 177 178 let tags = vec![ 179 vec!["e".to_string()], 180 vec!["amount".to_string(), "not-a-number".to_string()], 181 ]; 182 let err = job_result_from_tags(KIND_JOB_RESULT_MIN + 1, &tags, "payload").unwrap_err(); 183 assert!(matches!(err, JobParseError::InvalidTag("e"))); 184 185 let tags = vec![ 186 vec!["e".to_string(), "req".to_string()], 187 vec!["amount".to_string(), "not-a-number".to_string()], 188 ]; 189 let err = job_result_from_tags(KIND_JOB_RESULT_MIN + 1, &tags, "payload").unwrap_err(); 190 assert!(matches!(err, JobParseError::InvalidNumber("amount", _))); 191 } 192 193 #[test] 194 fn job_result_metadata_rejects_wrong_kind() { 195 let err = radroots_event_codec::decode::job::result::data_from_event( 196 "id".to_string(), 197 "author".to_string(), 198 1, 199 KIND_JOB_REQUEST_MIN, 200 "payload".to_string(), 201 Vec::new(), 202 ) 203 .unwrap_err(); 204 205 assert!(matches!( 206 err, 207 JobParseError::InvalidTag("kind (expected 6000-6999)") 208 )); 209 } 210 211 #[test] 212 fn job_result_data_from_event_success_path() { 213 let result = sample_result(); 214 let content = result.content.clone().unwrap(); 215 let parts = to_wire_parts(&result, &content).expect("wire parts"); 216 let data = radroots_event_codec::decode::job::result::data_from_event( 217 "id".to_string(), 218 "author".to_string(), 219 1, 220 parts.kind, 221 content, 222 parts.tags, 223 ) 224 .expect("job result data"); 225 assert_eq!(data.id, "id"); 226 assert_eq!(data.author, "author"); 227 assert_eq!(data.kind, KIND_JOB_RESULT_MIN + 1); 228 assert_eq!(data.data.request_event.id, "req"); 229 } 230 231 #[test] 232 fn job_result_data_from_event_propagates_decode_errors_with_valid_kind() { 233 let err = radroots_event_codec::decode::job::result::data_from_event( 234 "id".to_string(), 235 "author".to_string(), 236 1, 237 KIND_JOB_RESULT_MIN + 1, 238 "payload".to_string(), 239 Vec::new(), 240 ) 241 .unwrap_err(); 242 assert!(matches!(err, JobParseError::MissingTag("e"))); 243 } 244 245 #[test] 246 fn job_result_index_from_event_propagates_parse_errors() { 247 let err = parsed_from_event( 248 "id".to_string(), 249 "author".to_string(), 250 1, 251 KIND_JOB_REQUEST_MIN, 252 "payload".to_string(), 253 Vec::new(), 254 "sig".to_string(), 255 ) 256 .unwrap_err(); 257 assert!(matches!( 258 err, 259 JobParseError::InvalidTag("kind (expected 6000-6999)") 260 )); 261 }