composer_contract.rs (11466B)
1 use tera_ffi::*; 2 mod support; 3 4 #[tokio::test] 5 async fn legacy_inventory_is_versioned_paged_and_separate_from_partial_composers() { 6 let (root, runtime) = support::runtime().await; 7 runtime.composer_save(request()).await.unwrap(); 8 let mut saved = Vec::new(); 9 for _ in 0..2 { 10 saved.push( 11 runtime 12 .phase1_save_add_intent(legacy_input(), None, None) 13 .await 14 .unwrap(), 15 ); 16 } 17 let first = runtime.legacy_draft_page(1, 1, None).await.unwrap(); 18 assert_eq!(first.schema_version, 1); 19 assert_eq!(first.author_public_key, support::PUBLIC_KEY); 20 assert_eq!(first.entries.len(), 1); 21 let FfiLegacyDraftListEntry::Draft { summary } = &first.entries[0] else { 22 panic!("valid legacy summary") 23 }; 24 assert_eq!(summary.schema_version, 1); 25 assert_eq!(summary.revision, 1); 26 assert_eq!(summary.command_type, FfiAddCommandType::CreateUpdate); 27 assert_eq!(summary.state, FfiOutboxState::Draft); 28 assert!(summary.has_form && !summary.is_revision); 29 assert_eq!(summary.media_count, 0); 30 assert_eq!(summary.verified_media_count, 0); 31 assert_eq!(summary.possible_orphan_count, 0); 32 assert!(summary.settlement.is_none()); 33 let first_id = summary.draft_id.clone(); 34 for (version, limit, cursor, code) in [ 35 (2, 1, None, "unsupported_schema_version"), 36 (1, 0, None, "draft_inventory_invalid"), 37 (1, 257, None, "draft_inventory_invalid"), 38 ( 39 1, 40 1, 41 Some("invalid".to_owned()), 42 "draft_inventory_cursor_invalid", 43 ), 44 ] { 45 assert_eq!( 46 runtime 47 .legacy_draft_page(version, limit, cursor) 48 .await 49 .unwrap_err() 50 .report() 51 .code, 52 code 53 ); 54 } 55 runtime.shutdown().await.unwrap(); 56 let runtime = TeraRuntime::new( 57 root.path().to_string_lossy().into_owned(), 58 support::PUBLIC_KEY.into(), 59 support::GENERATION.into(), 60 1_800_000_000_000, 61 ProtectedDataAvailability::Available, 62 ) 63 .await 64 .unwrap(); 65 let second = runtime 66 .legacy_draft_page(1, 1, first.next_cursor) 67 .await 68 .unwrap(); 69 assert!(second.next_cursor.is_none()); 70 let FfiLegacyDraftListEntry::Draft { summary } = &second.entries[0] else { 71 panic!("valid second summary") 72 }; 73 let mut actual = vec![first_id, summary.draft_id.clone()]; 74 actual.sort(); 75 let mut expected: Vec<_> = saved.iter().map(|draft| draft.draft_id.clone()).collect(); 76 expected.sort(); 77 assert_eq!(actual, expected); 78 for draft in saved { 79 assert_eq!( 80 runtime 81 .phase1_draft_status(draft.draft_id.clone()) 82 .await 83 .unwrap(), 84 draft 85 ); 86 } 87 runtime.shutdown().await.unwrap(); 88 } 89 90 fn legacy_input() -> FfiAddDraftInput { 91 FfiAddDraftInput { 92 schema_version: 1, 93 command_type: FfiAddCommandType::CreateUpdate, 94 content: "legacy fixture".into(), 95 identifier: None, 96 title: None, 97 summary: None, 98 location: None, 99 event_timing: None, 100 event_start_date: None, 101 event_end_date: None, 102 event_start_unix_s: None, 103 event_end_unix_s: None, 104 event_timezone: None, 105 price_amount: None, 106 currency: None, 107 unit: None, 108 quantity: None, 109 food_published_at_unix_s: None, 110 food_status: None, 111 media: Vec::new(), 112 } 113 } 114 115 fn scope() -> FfiComposerScopeRecord { 116 FfiComposerScopeRecord { 117 schema_version: 1, 118 author_public_key: support::PUBLIC_KEY.into(), 119 local_network_id: "nearby".into(), 120 } 121 } 122 123 fn request() -> FfiComposerSaveRequest { 124 FfiComposerSaveRequest { 125 schema_version: 1, 126 scope: scope(), 127 id: composer_reserve_id().unwrap().id, 128 expected_revision: None, 129 edit_sequence: u64::MAX - 1, 130 form: FfiComposerFormRecord { 131 schema_version: 1, 132 command_type: FfiAddCommandType::CreateEvent, 133 content: " private partial\n\0é ".into(), 134 identifier: None, 135 title: None, 136 summary: None, 137 location: None, 138 event_timing: Some(FfiEventTimingKind::AllDay), 139 event_start_date: Some("2026-09-".into()), 140 event_end_date: None, 141 event_start_unix_s: Some(u64::MAX), 142 event_end_unix_s: None, 143 event_timezone: None, 144 price_amount: Some("12.".into()), 145 currency: None, 146 unit: None, 147 quantity: Some("-".into()), 148 food_published_at_unix_s: None, 149 food_status: None, 150 media: vec![FfiComposerMediaRecord { 151 schema_version: 1, 152 opaque_reference: "media:abc".into(), 153 sha256: "ab".repeat(32), 154 media_type: "image/png".into(), 155 byte_size: 24, 156 width: 2, 157 height: 2, 158 alt: String::new(), 159 prepared_at_unix_s: 1_800_000_000, 160 }], 161 }, 162 } 163 } 164 165 #[tokio::test] 166 async fn typed_composer_preserves_partial_fields_exact_receipts_and_restart_without_signing() { 167 let (root, runtime) = support::runtime().await; 168 let mut request = request(); 169 let created = runtime.composer_save(request.clone()).await.unwrap(); 170 assert_eq!(created.schema_version, 1); 171 assert_eq!(created.draft.scope, request.scope); 172 assert_eq!(created.draft.id, request.id); 173 assert_eq!(created.draft.revision, 1); 174 assert_eq!(created.draft.edit_sequence, u64::MAX - 1); 175 assert_eq!(created.draft.form, request.form); 176 assert!(!created.replayed); 177 assert!(!format!("{created:?}").contains("private partial")); 178 request.expected_revision = Some(1); 179 request.edit_sequence = u64::MAX; 180 request.form.content = "newest incomplete".into(); 181 let saved = runtime.composer_save(request.clone()).await.unwrap(); 182 assert_eq!(saved.draft.revision, 2); 183 assert_eq!(saved.draft.edit_sequence, u64::MAX); 184 let failure = runtime.composer_save(request.clone()).await.unwrap_err(); 185 assert_eq!(failure.report().code, "composer_revision_conflict"); 186 assert_eq!(failure.report().recovery_actions, ["reload_composer"]); 187 assert_eq!( 188 classify_error_recovery(1, failure.report().code.clone()).disposition, 189 FfiRecoveryDisposition::StaleRevision 190 ); 191 let page = runtime.composer_list(1, scope(), 1, None).await.unwrap(); 192 assert_eq!(page.scope, scope()); 193 assert_eq!(page.entries.len(), 1); 194 let FfiComposerListEntry::Draft { summary } = &page.entries[0] else { 195 panic!("summary") 196 }; 197 assert_eq!(summary.id, request.id); 198 assert_eq!(summary.revision, 2); 199 assert_eq!(summary.edit_sequence, u64::MAX); 200 assert!(page.next_cursor.is_none()); 201 assert!(runtime.phase1_draft_heads(100).await.unwrap().is_empty()); 202 runtime.shutdown().await.unwrap(); 203 let reopened = TeraRuntime::new( 204 root.path().to_string_lossy().into_owned(), 205 support::PUBLIC_KEY.into(), 206 support::GENERATION.into(), 207 1_800_000_000_000, 208 ProtectedDataAvailability::Available, 209 ) 210 .await 211 .unwrap(); 212 assert_eq!( 213 reopened 214 .composer_load(scope(), request.id.clone()) 215 .await 216 .unwrap(), 217 saved.draft 218 ); 219 let mut foreign = scope(); 220 foreign.local_network_id = "elsewhere".into(); 221 assert_eq!( 222 reopened 223 .composer_load(foreign.clone(), request.id.clone()) 224 .await 225 .unwrap_err() 226 .report() 227 .code, 228 "composer_scope_mismatch" 229 ); 230 assert!( 231 reopened 232 .composer_list(1, foreign, 10, None) 233 .await 234 .unwrap() 235 .entries 236 .is_empty() 237 ); 238 let mut foreign = scope(); 239 foreign.author_public_key = 240 "c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5".into(); 241 assert_eq!( 242 reopened 243 .composer_load(foreign, request.id) 244 .await 245 .unwrap_err() 246 .report() 247 .code, 248 "composer_scope_mismatch" 249 ); 250 reopened.shutdown().await.unwrap(); 251 assert_eq!( 252 reopened 253 .composer_list(1, scope(), 10, None) 254 .await 255 .unwrap_err() 256 .report() 257 .code, 258 "client_closed" 259 ); 260 } 261 262 #[tokio::test] 263 async fn typed_composer_rejects_versions_ids_and_bounds_before_writing_any_record() { 264 let (_root, runtime) = support::runtime().await; 265 let baseline = request(); 266 let mut invalid = Vec::new(); 267 let mut value = baseline.clone(); 268 value.schema_version = 2; 269 invalid.push((value, "composer_schema_unsupported")); 270 let mut value = baseline.clone(); 271 value.scope.schema_version = 2; 272 invalid.push((value, "composer_schema_unsupported")); 273 let mut value = baseline.clone(); 274 value.form.schema_version = 2; 275 invalid.push((value, "composer_schema_unsupported")); 276 let mut value = baseline.clone(); 277 value.form.media[0].schema_version = 2; 278 invalid.push((value, "composer_schema_unsupported")); 279 let mut value = baseline.clone(); 280 value.id = "00".repeat(16); 281 invalid.push((value, "composer_id_invalid")); 282 let mut value = baseline.clone(); 283 value.id = "AB".repeat(16); 284 invalid.push((value, "composer_id_invalid")); 285 let mut value = baseline.clone(); 286 value.expected_revision = Some(u64::MAX); 287 invalid.push((value, "composer_revision_invalid")); 288 let mut value = baseline.clone(); 289 value.edit_sequence = 0; 290 invalid.push((value, "composer_edit_sequence_invalid")); 291 let mut value = baseline.clone(); 292 value.scope.local_network_id = " nearby".into(); 293 invalid.push((value, "composer_scope_invalid")); 294 let mut value = baseline.clone(); 295 value.form.content = "é".repeat(32_768); 296 invalid.push((value, "composer_form_invalid")); 297 let mut value = baseline.clone(); 298 value.form.media[0].opaque_reference = "file:/private".into(); 299 invalid.push((value, "composer_form_invalid")); 300 for (input, code) in invalid { 301 let error = runtime.composer_save(input).await.unwrap_err(); 302 assert_eq!(error.report().schema_version, 1); 303 assert_eq!(error.report().code, code); 304 assert!(!error.report().retryable); 305 assert!(!format!("{error:?}").contains("private partial")); 306 } 307 assert_eq!( 308 runtime 309 .composer_list(2, scope(), 10, None) 310 .await 311 .unwrap_err() 312 .report() 313 .code, 314 "composer_schema_unsupported" 315 ); 316 assert_eq!( 317 runtime 318 .composer_list(1, scope(), 0, None) 319 .await 320 .unwrap_err() 321 .report() 322 .code, 323 "composer_list_invalid" 324 ); 325 assert_eq!( 326 runtime 327 .composer_list(1, scope(), 10, Some("future".into())) 328 .await 329 .unwrap_err() 330 .report() 331 .code, 332 "composer_cursor_invalid" 333 ); 334 assert!( 335 runtime 336 .composer_list(1, scope(), 10, None) 337 .await 338 .unwrap() 339 .entries 340 .is_empty() 341 ); 342 runtime.shutdown().await.unwrap(); 343 }