force-sync 0.2.0

Correctness-first bidirectional Salesforce and Postgres sync engine
Documentation
//! Planner and merge logic tests.

use chrono::Utc;
use proptest::prelude::*;
use serde_json::{Value, json};

use force_sync::SyncKey;
use force_sync::{ApplyLane, MergeOutcome, PlannerContext, merge_payload, plan_change};
use force_sync::{ChangeEnvelope, ChangeOperation, SourceSystem};
use force_sync::{ObjectSync, Owner};

fn sync_key() -> SyncKey {
    match SyncKey::new("tenant", "Account", "abc") {
        Ok(sync_key) => sync_key,
        Err(error) => panic!("unexpected sync key construction error: {error}"),
    }
}

fn envelope(payload: Value) -> ChangeEnvelope {
    ChangeEnvelope::new(
        sync_key(),
        SourceSystem::Salesforce,
        ChangeOperation::Upsert,
        Utc::now(),
        payload,
    )
}

const fn base_context(object: ObjectSync) -> PlannerContext {
    PlannerContext {
        object,
        current_payload: None,
        batch_size: 1,
        urgent: true,
        has_dependencies: false,
    }
}

#[test]
fn identical_hashes_become_noop() {
    let object = ObjectSync::new("Account").external_id("External_Id__c");
    let payload = json!({
        "Description": "Keep",
        "Name": "Acme"
    });
    let context = PlannerContext {
        current_payload: Some(json!({
            "Name": "Acme",
            "Description": "Keep"
        })),
        ..base_context(object)
    };

    let decision = plan_change(&context, &envelope(payload));

    assert_eq!(decision.lane, ApplyLane::Noop);
    assert!(decision.payload.is_none());
    assert!(decision.conflicts.is_empty());
}

#[test]
fn postgres_owned_field_wins_when_salesforce_changes_it() {
    let object = ObjectSync::new("Account")
        .external_id("External_Id__c")
        .field_owner("Name", Owner::Postgres);
    let current_payload = json!({
        "Description": "Keep",
        "Name": "Old"
    });
    let context = PlannerContext {
        current_payload: Some(json!({
            "Description": "Keep",
            "Name": "Old"
        })),
        ..base_context(object)
    };

    let decision = plan_change(
        &context,
        &envelope(json!({
            "Description": "Keep",
            "Name": "New"
        })),
    );

    assert_eq!(
        merge_payload(
            &context.object,
            context.current_payload.as_ref(),
            SourceSystem::Salesforce,
            &json!({
                "Description": "Keep",
                "Name": "New"
            }),
        ),
        MergeOutcome::Merged(current_payload)
    );
    assert_eq!(decision.lane, ApplyLane::Noop);
    assert!(decision.payload.is_none());
    assert!(decision.conflicts.is_empty());
}

#[test]
fn conflict_required_field_opens_a_conflict() {
    let object = ObjectSync::new("Account")
        .external_id("External_Id__c")
        .field_owner("Name", Owner::Shared);
    let context = PlannerContext {
        current_payload: Some(json!({
            "Description": "Keep",
            "Name": "Old"
        })),
        ..base_context(object)
    };

    let decision = plan_change(
        &context,
        &envelope(json!({
            "Description": "Keep",
            "Name": "New"
        })),
    );

    assert_eq!(decision.lane, ApplyLane::Conflict);
    assert!(decision.payload.is_none());
    assert!(decision.conflicts.iter().any(|field| field == "Name"));
}

#[test]
fn large_batches_choose_bulk() {
    let object = ObjectSync::new("Account").external_id("External_Id__c");
    let context = PlannerContext {
        batch_size: 500,
        urgent: false,
        ..base_context(object)
    };

    let decision = plan_change(&context, &envelope(json!({"Name": "Acme"})));

    assert_eq!(decision.lane, ApplyLane::Bulk);
}

#[test]
fn dependent_records_choose_composite_graph() {
    let object = ObjectSync::new("Account").external_id("External_Id__c");
    let context = PlannerContext {
        urgent: true,
        has_dependencies: true,
        ..base_context(object)
    };

    let decision = plan_change(&context, &envelope(json!({"Name": "Acme"})));

    assert_eq!(decision.lane, ApplyLane::CompositeGraph);
}

#[test]
fn urgent_singleton_changes_choose_rest() {
    let object = ObjectSync::new("Account").external_id("External_Id__c");
    let context = PlannerContext {
        urgent: true,
        batch_size: 1,
        ..base_context(object)
    };

    let decision = plan_change(&context, &envelope(json!({"Name": "Acme"})));

    assert_eq!(decision.lane, ApplyLane::Rest);
}

proptest! {
    #[test]
    fn duplicate_envelopes_plan_to_noop(fields in prop::collection::btree_map(
        "[a-z]{1,8}",
        "[A-Za-z0-9]{0,8}",
        0..4,
    )) {
        let object = ObjectSync::new("Account").external_id("External_Id__c");
        let payload = match serde_json::to_value(fields) {
            Ok(payload) => payload,
            Err(error) => panic!("unexpected json conversion error: {error}"),
        };
        let context = PlannerContext {
            current_payload: Some(payload.clone()),
            ..base_context(object)
        };

        let decision = plan_change(&context, &envelope(payload));

        prop_assert_eq!(decision.lane, ApplyLane::Noop);
        prop_assert!(decision.payload.is_none());
        prop_assert!(decision.conflicts.is_empty());
    }
}