field_ios

In-the-field app for Radroots on iOS
git clone https://radroots.dev/git/field_ios.git
Log | Files | Refs | README | LICENSE

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 }