Ctrl K
ComunidadGuías…

src/test_helpers.rs

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
//! Shared test fixtures and builders.
//!
//! Provides ergonomic builders for commonly constructed test objects
//! and reusable mock implementations of core traits.

pub(crate) mod allocation;

use std::collections::VecDeque;
use std::sync::{Arc, Mutex};

use chrono::{DateTime, Utc};
use serde_json::{Value, json};

use crate::icloud::photos::PhotoAsset;
use crate::icloud::photos::session::PhotosSession;
use crate::state::types::{AssetMetadata, AssetRecord, MediaType, VersionSizeKey};

/// Synthetic QuickTime track metadata without an ISO FullBox prefix.
#[cfg(feature = "xmp")]
pub(crate) fn bare_track_meta_movie() -> Vec<u8> {
    let atom = |kind: &[u8; 4], body: &[u8]| {
        let mut bytes = u32::try_from(body.len() + 8)
            .unwrap()
            .to_be_bytes()
            .to_vec();
        bytes.extend_from_slice(kind);
        bytes.extend_from_slice(body);
        bytes
    };
    let mut handler = vec![0; 24];
    handler[8..12].copy_from_slice(b"mdta");
    let track_meta = atom(b"meta", &atom(b"hdlr", &handler));
    let mut movie = atom(b"mvhd", &[0; 100]);
    movie.extend(atom(b"trak", &track_meta));
    let mut original = atom(b"ftyp", b"qt  \0\0\0\0qt  ");
    original.extend(atom(b"moov", &movie));
    original.extend(atom(b"mdat", b"unchanged media"));
    original
}

/// Supported synthetic QuickTime metadata with a caller-selected user-data tail.
#[cfg(feature = "xmp")]
pub(crate) fn quicktime_user_data_movie(tail: &[u8]) -> Vec<u8> {
    let mut bytes = bare_track_meta_movie();
    let meta = bytes.windows(4).position(|value| value == b"meta").unwrap();
    bytes.splice(meta + 4..meta + 4, [0; 4]);
    for kind in [b"moov", b"trak", b"meta"] {
        let start = bytes.windows(4).position(|value| value == kind).unwrap() - 4;
        let size = u32::from_be_bytes(bytes[start..start + 4].try_into().unwrap());
        bytes[start..start + 4].copy_from_slice(&(size + 4).to_be_bytes());
    }
    let mut user_data = b"\0\0\0\x14udta\0\0\0\x0cnamekept".to_vec();
    user_data.extend_from_slice(tail);
    let size = u32::try_from(user_data.len()).unwrap();
    user_data[..4].copy_from_slice(&size.to_be_bytes());
    let movie = bytes.windows(4).position(|value| value == b"moov").unwrap() - 4;
    let size = u32::from_be_bytes(bytes[movie..movie + 4].try_into().unwrap());
    let end = movie + usize::try_from(size).unwrap();
    bytes[movie..movie + 4]
        .copy_from_slice(&(size + u32::try_from(user_data.len()).unwrap()).to_be_bytes());
    bytes.splice(end..end, user_data);
    bytes
}

/// Synthetic sparse-share shape reported in #765, with invented identifiers.
#[cfg(test)]
pub(crate) fn sparse_shared_asset_record() -> Value {
    serde_json::from_str(include_str!("../tests/data/sparse_shared_asset.json"))
        .expect("synthetic sparse-share fixture must be valid JSON")
}

#[cfg(test)]
fn loopback_bind_unavailable_reason() -> Option<String> {
    static RESULT: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new();
    RESULT
        .get_or_init(|| {
            let addr = std::net::SocketAddr::from((std::net::Ipv4Addr::LOCALHOST, 0));
            match std::net::TcpListener::bind(addr) {
                Ok(listener) => {
                    drop(listener);
                    None
                }
                Err(e)
                    if e.kind() == std::io::ErrorKind::PermissionDenied
                        || e.raw_os_error() == Some(1) =>
                {
                    Some(format!("loopback bind is not permitted on this host: {e}"))
                }
                Err(e) => panic!("loopback bind probe failed unexpectedly: {e}"),
            }
        })
        .clone()
}

#[cfg(test)]
pub(crate) fn skip_if_loopback_bind_blocked(test_name: &str) -> bool {
    if let Some(reason) = loopback_bind_unavailable_reason() {
        eprintln!("skipping {test_name}: {reason}");
        true
    } else {
        false
    }
}

#[cfg(test)]
pub(crate) async fn start_wiremock_or_skip(test_name: &str) -> Option<wiremock::MockServer> {
    if skip_if_loopback_bind_blocked(test_name) {
        None
    } else {
        Some(wiremock::MockServer::start().await)
    }
}

#[cfg(test)]
#[macro_export]
macro_rules! start_wiremock_or_skip {
    () => {{
        match $crate::test_helpers::start_wiremock_or_skip(module_path!()).await {
            Some(server) => server,
            None => return,
        }
    }};
    ($test_name:expr) => {{
        match $crate::test_helpers::start_wiremock_or_skip($test_name).await {
            Some(server) => server,
            None => return,
        }
    }};
}

// ── Tracing capture helper ─────────────────────────────────────────

#[cfg(test)]
#[derive(Debug, Clone)]
pub struct CapturedEvent {
    pub level: tracing::Level,
    pub fields: std::collections::HashMap<String, String>,
}

#[cfg(test)]
impl CapturedEvent {
    pub fn field(&self, name: &str) -> Option<&str> {
        self.fields.get(name).map(String::as_str)
    }

    pub fn message(&self) -> Option<&str> {
        self.field("message")
    }
}

#[cfg(test)]
#[derive(Clone, Default)]
pub struct TracingCapture {
    events: Arc<Mutex<Vec<CapturedEvent>>>,
}

#[cfg(test)]
impl TracingCapture {
    pub fn install() -> (Self, tracing::subscriber::DefaultGuard) {
        use tracing_subscriber::prelude::*;

        let capture = Self::default();
        let subscriber = tracing_subscriber::registry().with(CaptureLayer {
            events: Arc::clone(&capture.events),
        });
        let guard = tracing::subscriber::set_default(subscriber);
        (capture, guard)
    }

    pub fn events(&self) -> Vec<CapturedEvent> {
        self.events
            .lock()
            .unwrap_or_else(std::sync::PoisonError::into_inner)
            .clone()
    }

    pub fn contains_event(&self, predicate: impl Fn(&CapturedEvent) -> bool) -> bool {
        self.events().iter().any(predicate)
    }
}

#[cfg(test)]
struct CaptureLayer {
    events: Arc<Mutex<Vec<CapturedEvent>>>,
}

#[cfg(test)]
impl<S> tracing_subscriber::Layer<S> for CaptureLayer
where
    S: tracing::Subscriber,
{
    fn on_event(
        &self,
        event: &tracing::Event<'_>,
        _ctx: tracing_subscriber::layer::Context<'_, S>,
    ) {
        let mut visitor = FieldVisitor::default();
        event.record(&mut visitor);
        self.events
            .lock()
            .unwrap_or_else(std::sync::PoisonError::into_inner)
            .push(CapturedEvent {
                level: *event.metadata().level(),
                fields: visitor.fields,
            });
    }
}

#[cfg(test)]
#[derive(Default)]
struct FieldVisitor {
    fields: std::collections::HashMap<String, String>,
}

#[cfg(test)]
impl tracing::field::Visit for FieldVisitor {
    fn record_str(&mut self, field: &tracing::field::Field, value: &str) {
        self.fields
            .insert(field.name().to_string(), value.to_string());
    }

    fn record_bool(&mut self, field: &tracing::field::Field, value: bool) {
        self.fields
            .insert(field.name().to_string(), value.to_string());
    }

    fn record_i64(&mut self, field: &tracing::field::Field, value: i64) {
        self.fields
            .insert(field.name().to_string(), value.to_string());
    }

    fn record_u64(&mut self, field: &tracing::field::Field, value: u64) {
        self.fields
            .insert(field.name().to_string(), value.to_string());
    }

    fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
        self.fields
            .insert(field.name().to_string(), format!("{value:?}"));
    }
}

// ── AssetRecord builder ─────────────────────────────────────────────

/// Builder for `AssetRecord::new_pending()` with sensible test defaults.
///
/// ```ignore
/// let record = TestAssetRecord::new("MY_ID").build();
/// let record = TestAssetRecord::new("MY_ID").checksum("abc").size(5000).build();
/// ```
pub struct TestAssetRecord {
    library: String,
    id: String,
    version_size: VersionSizeKey,
    checksum: String,
    filename: String,
    created_at: DateTime<Utc>,
    added_at: Option<DateTime<Utc>>,
    size_bytes: u64,
    media_type: MediaType,
    metadata: Option<AssetMetadata>,
}

impl TestAssetRecord {
    pub fn new(id: &str) -> Self {
        Self {
            library: crate::icloud::photos::PRIMARY_ZONE_NAME.to_string(),
            id: id.to_string(),
            version_size: VersionSizeKey::Original,
            checksum: "checksum123".to_string(),
            filename: "photo.jpg".to_string(),
            created_at: Utc::now(),
            added_at: None,
            size_bytes: 12345,
            media_type: MediaType::Photo,
            metadata: None,
        }
    }

    pub fn library(mut self, library: &str) -> Self {
        self.library = library.to_string();
        self
    }

    pub fn checksum(mut self, c: &str) -> Self {
        self.checksum = c.to_string();
        self
    }

    pub fn filename(mut self, f: &str) -> Self {
        self.filename = f.to_string();
        self
    }

    pub fn created_at(mut self, t: DateTime<Utc>) -> Self {
        self.created_at = t;
        self
    }

    pub fn added_at(mut self, t: DateTime<Utc>) -> Self {
        self.added_at = Some(t);
        self
    }

    pub fn size(mut self, s: u64) -> Self {
        self.size_bytes = s;
        self
    }

    pub fn media_type(mut self, m: MediaType) -> Self {
        self.media_type = m;
        self
    }

    pub fn metadata(mut self, m: AssetMetadata) -> Self {
        self.metadata = Some(m);
        self
    }

    pub fn version_size(mut self, v: VersionSizeKey) -> Self {
        self.version_size = v;
        self
    }

    pub fn build(self) -> AssetRecord {
        let record = AssetRecord::new_pending(
            std::sync::Arc::from(self.library),
            self.id,
            self.version_size,
            self.checksum,
            self.filename,
            self.created_at,
            self.added_at,
            self.size_bytes,
            self.media_type,
        );
        if let Some(meta) = self.metadata {
            record.with_metadata(meta)
        } else {
            record
        }
    }
}

// ── PhotoAsset builder ──────────────────────────────────────────────

/// Builder for `PhotoAsset::new()` with sensible test defaults.
///
/// ```ignore
/// let asset = TestPhotoAsset::new("TEST_1").build();
/// let asset = TestPhotoAsset::new("LIVE_1")
///     .filename("IMG_0001.HEIC")
///     .item_type("public.heic")
///     .orig_file_type("public.heic")
///     .live_photo("https://p01.icloud-content.com/mov", "mov_ck", 3000)
///     .build();
/// ```
pub struct TestPhotoAsset {
    record_name: String,
    filename: String,
    item_type: String,
    orig_size: u64,
    orig_url: String,
    orig_checksum: String,
    orig_file_type: String,
    asset_date: f64,
    timezone_offset: Option<i32>,
    favorite: bool,
    live_photo: Option<LivePhotoFields>,
    adjusted_version: Option<AdjustedVersionFields>,
    live_adjusted: Option<LivePhotoFields>,
    alt_version: Option<AltVersionFields>,
}

struct LivePhotoFields {
    url: String,
    checksum: String,
    size: u64,
}

struct AltVersionFields {
    url: String,
    checksum: String,
    size: u64,
    file_type: String,
}

struct AdjustedVersionFields {
    url: String,
    checksum: String,
    size: u64,
    file_type: String,
}

impl TestPhotoAsset {
    pub fn new(record_name: &str) -> Self {
        Self {
            record_name: record_name.to_string(),
            filename: "photo.jpg".to_string(),
            item_type: "public.jpeg".to_string(),
            orig_size: 1000,
            orig_url: "https://p01.icloud-content.com/orig".to_string(),
            orig_checksum: "abc123".to_string(),
            orig_file_type: "public.jpeg".to_string(),
            asset_date: 1736899200000.0,
            timezone_offset: None,
            favorite: false,
            live_photo: None,
            adjusted_version: None,
            live_adjusted: None,
            alt_version: None,
        }
    }

    pub fn filename(mut self, f: &str) -> Self {
        self.filename = f.to_string();
        self
    }

    pub fn item_type(mut self, t: &str) -> Self {
        self.item_type = t.to_string();
        self
    }

    pub fn orig_size(mut self, s: u64) -> Self {
        self.orig_size = s;
        self
    }

    pub fn orig_url(mut self, u: &str) -> Self {
        self.orig_url = u.to_string();
        self
    }

    pub fn orig_checksum(mut self, c: &str) -> Self {
        self.orig_checksum = c.to_string();
        self
    }

    pub fn orig_file_type(mut self, t: &str) -> Self {
        self.orig_file_type = t.to_string();
        self
    }

    pub fn asset_date(mut self, d: f64) -> Self {
        self.asset_date = d;
        self
    }

    pub fn timezone_offset(mut self, seconds: i32) -> Self {
        self.timezone_offset = Some(seconds);
        self
    }

    /// Toggle the source favourite flag, which iCloud maps to a 5-star rating.
    pub fn favorite(mut self, favorite: bool) -> Self {
        self.favorite = favorite;
        self
    }

    pub fn live_photo(mut self, url: &str, checksum: &str, size: u64) -> Self {
        self.live_photo = Some(LivePhotoFields {
            url: url.to_string(),
            checksum: checksum.to_string(),
            size,
        });
        self
    }

    pub fn adjusted_version(
        mut self,
        url: &str,
        checksum: &str,
        size: u64,
        file_type: &str,
    ) -> Self {
        self.adjusted_version = Some(AdjustedVersionFields {
            url: url.to_string(),
            checksum: checksum.to_string(),
            size,
            file_type: file_type.to_string(),
        });
        self
    }

    pub fn live_adjusted(mut self, url: &str, checksum: &str, size: u64) -> Self {
        self.live_adjusted = Some(LivePhotoFields {
            url: url.to_string(),
            checksum: checksum.to_string(),
            size,
        });
        self
    }

    pub fn alt_version(mut self, url: &str, checksum: &str, size: u64, file_type: &str) -> Self {
        self.alt_version = Some(AltVersionFields {
            url: url.to_string(),
            checksum: checksum.to_string(),
            size,
            file_type: file_type.to_string(),
        });
        self
    }

    pub fn build(self) -> PhotoAsset {
        let mut fields = json!({
            "filenameEnc": {"value": self.filename, "type": "STRING"},
            "itemType": {"value": self.item_type},
            "resOriginalRes": {"value": {
                "size": self.orig_size,
                "downloadURL": self.orig_url,
                "fileChecksum": self.orig_checksum,
            }},
            "resOriginalFileType": {"value": self.orig_file_type},
        });

        if let Some(lp) = &self.live_photo {
            fields["resOriginalVidComplRes"] = json!({"value": {
                "size": lp.size,
                "downloadURL": lp.url,
                "fileChecksum": lp.checksum,
            }});
            fields["resOriginalVidComplFileType"] = json!({"value": "com.apple.quicktime-movie"});
        }

        if let Some(adjusted) = &self.adjusted_version {
            fields["resJPEGFullRes"] = json!({"value": {
                "size": adjusted.size,
                "downloadURL": adjusted.url,
                "fileChecksum": adjusted.checksum,
            }});
            fields["resJPEGFullFileType"] = json!({"value": adjusted.file_type});
        }

        if let Some(lp) = &self.live_adjusted {
            fields["resVidComplRes"] = json!({"value": {
                "size": lp.size,
                "downloadURL": lp.url,
                "fileChecksum": lp.checksum,
            }});
            fields["resVidComplFileType"] = json!({"value": "com.apple.quicktime-movie"});
        }

        if let Some(alt) = &self.alt_version {
            fields["resOriginalAltRes"] = json!({"value": {
                "size": alt.size,
                "downloadURL": alt.url,
                "fileChecksum": alt.checksum,
            }});
            fields["resOriginalAltFileType"] = json!({"value": alt.file_type});
        }

        let master = json!({
            "recordName": self.record_name,
            "fields": fields,
        });
        let mut asset = json!({
            "fields": {
                "assetDate": {"value": self.asset_date},
                "isFavorite": {"value": i64::from(self.favorite)},
            },
        });
        if let Some(offset) = self.timezone_offset {
            asset["fields"]["timeZoneOffset"] = json!({"value": offset});
        }
        PhotoAsset::new(master, asset)
    }
}

// ── CloudKit/Photos response flow builder ───────────────────────────

/// Small builder for the queued CloudKit responses used by `MockPhotosSession`.
///
/// This intentionally only covers the response shapes current tests repeat:
/// album-count batches, `/records/query` pages, `/changes/database` pages,
/// `/changes/zone` pages, and queued transport errors. It is not a fake
/// CloudKit implementation.
pub struct MockPhotosFlow {
    session: MockPhotosSession,
}

impl MockPhotosFlow {
    pub fn new() -> Self {
        Self {
            session: MockPhotosSession::new(),
        }
    }

    pub fn album_count(mut self, count: u64) -> Self {
        self.session = self.session.ok(json!({
            "batch": [{"records": [{"fields": {"itemCount": {"value": count}}}]}]
        }));
        self
    }

    pub fn album_count_response(mut self, response: Value) -> Self {
        self.session = self.session.ok(response);
        self
    }

    pub fn query_page(mut self, records: Vec<Value>, sync_token: Option<&str>) -> Self {
        let mut page = json!({ "records": records });
        if let Some(token) = sync_token {
            page["syncToken"] = json!(token);
        }
        self.session = self.session.ok(page);
        self
    }

    pub fn query_photo_page(mut self, record_name: &str, sync_token: Option<&str>) -> Self {
        self.session = self
            .session
            .ok(mock_photo_query_page(record_name, sync_token));
        self
    }

    pub fn empty_query_page(self, sync_token: Option<&str>) -> Self {
        self.query_page(Vec::new(), sync_token)
    }

    pub fn changes_database(
        mut self,
        sync_token: &str,
        changed_zones: &[(&str, &str)],
        more_coming: bool,
    ) -> Self {
        let zones: Vec<Value> = changed_zones
            .iter()
            .map(|(zone_name, zone_sync_token)| {
                json!({
                    "zoneID": {"zoneName": zone_name, "ownerRecordName": "_defaultOwner"},
                    "syncToken": zone_sync_token,
                })
            })
            .collect();
        self.session = self.session.ok(json!({
            "syncToken": sync_token,
            "moreComing": more_coming,
            "zones": zones,
        }));
        self
    }

    pub fn changes_zone_page(
        mut self,
        records: Vec<Value>,
        sync_token: &str,
        more_coming: bool,
    ) -> Self {
        self.session = self.session.ok(json!({
            "zones": [{
                "zoneID": {"zoneName": "PrimarySync", "ownerRecordName": "_defaultOwner"},
                "syncToken": sync_token,
                "moreComing": more_coming,
                "records": records,
            }]
        }));
        self
    }

    pub fn changes_photo_page(
        self,
        record_name: &str,
        sync_token: &str,
        more_coming: bool,
    ) -> Self {
        self.changes_zone_page(mock_photo_records(record_name), sync_token, more_coming)
    }

    pub fn changes_zone_error(
        mut self,
        server_error_code: &str,
        reason: &str,
        sync_token: &str,
    ) -> Self {
        self.session = self.session.ok(json!({
            "zones": [{
                "zoneID": {"zoneName": "PrimarySync", "ownerRecordName": "_defaultOwner"},
                "syncToken": sync_token,
                "moreComing": false,
                "serverErrorCode": server_error_code,
                "reason": reason,
            }]
        }));
        self
    }

    pub fn error(mut self, message: &str) -> Self {
        self.session = self.session.err(message);
        self
    }

    pub fn build(self) -> MockPhotosSession {
        self.session
    }
}

pub(crate) fn mock_photo_query_page(record_name: &str, sync_token: Option<&str>) -> Value {
    let mut page = json!({ "records": mock_photo_records(record_name) });
    if let Some(token) = sync_token {
        page["syncToken"] = json!(token);
    }
    page
}

fn mock_photo_records(record_name: &str) -> Vec<Value> {
    mock_photo_records_for_zone_with_filename(record_name, "PrimarySync", "test.jpg")
}

pub(crate) fn mock_photo_records_for_zone_with_filename(
    record_name: &str,
    zone: &str,
    filename: &str,
) -> Vec<Value> {
    mock_photo_records_for_zone_with_filename_and_asset_date(
        record_name,
        zone,
        filename,
        1700000000000,
    )
}

pub(crate) fn mock_photo_records_for_zone_with_filename_and_asset_date(
    record_name: &str,
    zone: &str,
    filename: &str,
    asset_date: i64,
) -> Vec<Value> {
    vec![
        json!({
            "recordName": record_name,
            "recordType": "CPLMaster",
            "fields": {
                "filenameEnc": {"value": filename, "type": "STRING"},
                "resOriginalRes": {
                    "value": {
                        "downloadURL": format!("https://p01.icloud-content.com/{record_name}.jpg"),
                        "size": 1024,
                        "fileChecksum": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
                    }
                },
                "resOriginalWidth": {"value": 100, "type": "INT64"},
                "resOriginalHeight": {"value": 100, "type": "INT64"},
                "resOriginalFileType": {"value": "public.jpeg"},
                "itemType": {"value": "public.jpeg"},
                "adjustmentRenderType": {"value": 0, "type": "INT64"}
            },
            "recordChangeTag": "ct1"
        }),
        json!({
            "recordName": format!("asset-{record_name}"),
            "recordType": "CPLAsset",
            "fields": {
                "masterRef": {
                        "value": {
                            "recordName": record_name,
                            "zoneID": {"zoneName": zone}
                        },
                        "type": "REFERENCE"
                    },
                "assetDate": {"value": asset_date, "type": "TIMESTAMP"},
                "addedDate": {"value": asset_date, "type": "TIMESTAMP"}
            },
            "recordChangeTag": "ct2"
        }),
    ]
}

#[derive(Clone, Debug)]
pub struct DynamicRecentPhotosSession {
    ids: Arc<Vec<String>>,
    zone: Arc<str>,
    token: Arc<str>,
    filename_prefix: Arc<str>,
    error_at_offset: Option<u64>,
    offsets: Arc<Mutex<Vec<u64>>>,
    results_limits: Arc<Mutex<Vec<u64>>>,
    emitted_ids: Arc<Mutex<Vec<String>>>,
}

impl DynamicRecentPhotosSession {
    pub fn new(total_assets: u64) -> Self {
        Self::from_ids(
            (0..total_assets)
                .map(|i| format!("master-{i:04}"))
                .collect(),
        )
    }

    pub fn from_ids(ids: Vec<String>) -> Self {
        Self {
            ids: Arc::new(ids),
            zone: Arc::from("PrimarySync"),
            token: Arc::from("zone-token"),
            filename_prefix: Arc::from("photo"),
            error_at_offset: None,
            offsets: Arc::new(Mutex::new(Vec::new())),
            results_limits: Arc::new(Mutex::new(Vec::new())),
            emitted_ids: Arc::new(Mutex::new(Vec::new())),
        }
    }

    pub fn with_zone(mut self, zone: &str) -> Self {
        self.zone = Arc::from(zone);
        self
    }

    pub fn with_token(mut self, token: &str) -> Self {
        self.token = Arc::from(token);
        self
    }

    pub fn with_filename_prefix(mut self, prefix: &str) -> Self {
        self.filename_prefix = Arc::from(prefix);
        self
    }

    pub fn with_error_at_offset(mut self, offset: u64) -> Self {
        self.error_at_offset = Some(offset);
        self
    }

    pub fn offsets(&self) -> Vec<u64> {
        self.offsets.lock().expect("offsets lock").clone()
    }

    pub fn results_limits(&self) -> Vec<u64> {
        self.results_limits
            .lock()
            .expect("results limits lock")
            .clone()
    }

    pub fn emitted_ids(&self) -> Vec<String> {
        self.emitted_ids.lock().expect("emitted ids lock").clone()
    }
}

#[async_trait::async_trait]
impl PhotosSession for DynamicRecentPhotosSession {
    async fn post(
        &self,
        url: &str,
        body: String,
        _headers: &[(&str, &str)],
    ) -> anyhow::Result<Value> {
        if url.contains("/internal/records/query/batch") {
            return Ok(json!({
                "batch": [{"records": [{"fields": {"itemCount": {"value": self.ids.len() as u64}}}]}]
            }));
        }

        if !url.contains("/records/query?") {
            return Ok(json!({"records": []}));
        }

        let request: Value = serde_json::from_str(&body)?;
        let offset = request["query"]["filterBy"]
            .as_array()
            .and_then(|filters| {
                filters.iter().find_map(|filter| {
                    (filter["fieldName"] == "startRank")
                        .then(|| filter["fieldValue"]["value"].as_u64())
                        .flatten()
                })
            })
            .unwrap_or(0);
        let results_limit = request["resultsLimit"].as_u64().unwrap_or(0);
        self.offsets.lock().expect("offsets lock").push(offset);
        self.results_limits
            .lock()
            .expect("results limits lock")
            .push(results_limit);

        if self
            .error_at_offset
            .is_some_and(|error_offset| offset >= error_offset)
        {
            return Err(anyhow::anyhow!(
                "simulated records/query failure at offset {offset}"
            ));
        }

        let start = usize::try_from(offset).unwrap_or(usize::MAX);
        let page_assets = usize::try_from(results_limit / 2).unwrap_or(usize::MAX);
        let end = start.saturating_add(page_assets).min(self.ids.len());
        if start >= end {
            return Ok(json!({"records": [], "syncToken": self.token.as_ref()}));
        }

        let mut emitted = self.emitted_ids.lock().expect("emitted ids lock");
        let mut records = Vec::with_capacity((end - start) * 2);
        for index in start..end {
            let id = &self.ids[index];
            emitted.push(id.clone());
            records.extend(mock_photo_records_for_zone_with_filename(
                id,
                &self.zone,
                &format!("{}-{index:04}.jpg", self.filename_prefix),
            ));
        }
        drop(emitted);

        Ok(json!({"records": records, "syncToken": self.token.as_ref()}))
    }

    fn clone_box(&self) -> Box<dyn PhotosSession> {
        Box::new(self.clone())
    }
}

impl Default for MockPhotosFlow {
    fn default() -> Self {
        Self::new()
    }
}

// ── Mock PhotosSession ──────────────────────────────────────────────

/// Recorded call to `MockPhotosSession::post()`.
#[derive(Debug, Clone)]
pub struct RecordedCall {
    pub url: String,
    pub _body: String,
}

/// Response action for a single `post()` call.
pub enum MockResponse {
    /// Return `Ok(value)`.
    Ok(Value),
    /// Return `Err(...)`.
    Err(String),
}

/// A configurable mock `PhotosSession` that supports:
/// - Sequenced responses (success or error per call)
/// - Call recording for assertion
/// - Fallback to empty `{"records": []}` when the queue is exhausted
///
/// ```ignore
/// let mock = MockPhotosSession::new()
///     .ok(json!({"records": [...]}))
///     .err("simulated failure")
///     .ok(json!({"records": []}));
/// ```
pub struct MockPhotosSession {
    responses: Mutex<VecDeque<MockResponse>>,
    calls: Mutex<Vec<RecordedCall>>,
}

impl MockPhotosSession {
    pub fn new() -> Self {
        Self {
            responses: Mutex::new(VecDeque::new()),
            calls: Mutex::new(Vec::new()),
        }
    }

    /// Queue a successful response.
    pub fn ok(self, value: Value) -> Self {
        self.responses
            .lock()
            .expect("poisoned")
            .push_back(MockResponse::Ok(value));
        self
    }

    /// Queue an error response.
    pub fn err(self, message: &str) -> Self {
        self.responses
            .lock()
            .expect("poisoned")
            .push_back(MockResponse::Err(message.to_string()));
        self
    }

    /// Return all recorded calls for assertion.
    pub fn recorded_calls(&self) -> Vec<RecordedCall> {
        self.calls.lock().expect("poisoned").clone()
    }

    /// Return the number of calls made.
    pub fn call_count(&self) -> usize {
        self.calls.lock().expect("poisoned").len()
    }
}

#[async_trait::async_trait]
impl PhotosSession for MockPhotosSession {
    async fn post(
        &self,
        url: &str,
        body: String,
        _headers: &[(&str, &str)],
    ) -> anyhow::Result<Value> {
        self.calls.lock().expect("poisoned").push(RecordedCall {
            url: url.to_string(),
            _body: body,
        });

        let response = {
            let mut responses = self.responses.lock().expect("poisoned");
            if url.contains("/internal/records/query/batch") {
                match responses.front() {
                    Some(MockResponse::Ok(value)) if value.get("batch").is_some() => {
                        responses.pop_front()
                    }
                    Some(MockResponse::Err(_)) => responses.pop_front(),
                    _ => None,
                }
            } else {
                responses.pop_front()
            }
        };

        match response {
            Some(MockResponse::Ok(v)) => Ok(v),
            Some(MockResponse::Err(msg)) => Err(anyhow::anyhow!("{msg}")),
            None if url.contains("/internal/records/query/batch") => Ok(json!({
                "batch": [{"records": [{"fields": {"itemCount": {"value": 0}}}]}]
            })),
            None => Ok(json!({"records": []})),
        }
    }

    fn clone_box(&self) -> Box<dyn PhotosSession> {
        let remaining: Vec<MockResponse> = {
            let queue = self.responses.lock().expect("poisoned");
            queue
                .iter()
                .map(|r| match r {
                    MockResponse::Ok(v) => MockResponse::Ok(v.clone()),
                    MockResponse::Err(msg) => MockResponse::Err(msg.clone()),
                })
                .collect()
        };
        let mut new = MockPhotosSession::new();
        *new.responses.get_mut().unwrap() = remaining.into();
        Box::new(new)
    }
}

// ── Source EXIF GPS fixtures ────────────────────────────────────────

/// Facts that `exif_with_source_gps` yields once read and composed.
#[cfg(feature = "xmp")]
pub const SOURCE_GPS_DATETIME: &str = "2024-06-15T10:20:30.125Z";
#[cfg(feature = "xmp")]
pub const SOURCE_GPS_SPEED: &str = "12345/100";
#[cfg(feature = "xmp")]
pub const SOURCE_GPS_SPEED_REF: &str = "K";
#[cfg(feature = "xmp")]
pub const SOURCE_GPS_H_POSITIONING_ERROR: &str = "3/2";

/// EXIF `uR64` rational from a numerator and denominator.
#[cfg(feature = "xmp")]
pub fn ur64(nominator: u32, denominator: u32) -> little_exif::rational::uR64 {
    little_exif::rational::uR64 {
        nominator,
        denominator,
    }
}

/// EXIF metadata carrying the standard source GPS facts: GPSDateStamp
/// 2024:06:15, GPSTimeStamp 10:20:30.125, GPSSpeedRef K, GPSSpeed 123.45,
/// GPSHPositioningError 1.5.
#[cfg(feature = "xmp")]
pub fn exif_with_source_gps() -> little_exif::metadata::Metadata {
    use little_exif::exif_tag::ExifTag;
    use little_exif::metadata::Metadata;

    let mut metadata = Metadata::new();
    metadata.set_tag(ExifTag::GPSDateStamp("2024:06:15".into()));
    metadata.set_tag(ExifTag::GPSTimeStamp(vec![
        ur64(10, 1),
        ur64(20, 1),
        ur64(30_125, 1_000),
    ]));
    metadata.set_tag(ExifTag::GPSSpeedRef("K".into()));
    metadata.set_tag(ExifTag::GPSSpeed(vec![ur64(12_345, 100)]));
    metadata.set_tag(ExifTag::GPSHPositioningError(vec![ur64(3, 2)]));
    metadata
}

/// The standard source GPS EXIF without a `GPSTimeStamp`, so a date alone
/// cannot compose a timestamp.
#[cfg(feature = "xmp")]
pub fn source_gps_without_time_stamp() -> little_exif::metadata::Metadata {
    use little_exif::exif_tag::ExifTag;
    use little_exif::metadata::Metadata;

    let mut metadata = Metadata::new();
    metadata.set_tag(ExifTag::GPSDateStamp("2024:06:15".into()));
    metadata.set_tag(ExifTag::GPSSpeedRef("K".into()));
    metadata.set_tag(ExifTag::GPSSpeed(vec![ur64(12_345, 100)]));
    metadata.set_tag(ExifTag::GPSHPositioningError(vec![ur64(3, 2)]));
    metadata
}

/// Minimal JPEG (SOI + APP0 JFIF + EOI) carrying the standard source GPS EXIF.
#[cfg(feature = "xmp")]
pub fn minimal_jpeg_with_source_gps() -> Vec<u8> {
    let mut bytes = vec![
        0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01, 0x01, 0x00, 0x00,
        0x01, 0x00, 0x01, 0x00, 0x00, 0xFF, 0xD9,
    ];
    exif_with_source_gps()
        .write_to_vec(&mut bytes, little_exif::filetype::FileExtension::JPEG)
        .expect("write source GPS EXIF into minimal JPEG");
    bytes
}

/// Minimal JPEG whose source EXIF location and capture time deliberately
/// differ from the CloudKit values used by metadata writer tests.
#[cfg(feature = "xmp")]
pub fn minimal_jpeg_with_source_gps_and_location() -> Vec<u8> {
    use little_exif::exif_tag::ExifTag;

    let mut metadata = exif_with_source_gps();
    metadata.set_tag(ExifTag::DateTimeOriginal("2030:01:02 03:04:05".into()));
    metadata.set_tag(ExifTag::GPSLatitudeRef("N".into()));
    metadata.set_tag(ExifTag::GPSLatitude(vec![
        ur64(1, 1),
        ur64(30, 1),
        ur64(0, 1),
    ]));
    metadata.set_tag(ExifTag::GPSLongitudeRef("E".into()));
    metadata.set_tag(ExifTag::GPSLongitude(vec![
        ur64(2, 1),
        ur64(30, 1),
        ur64(0, 1),
    ]));
    metadata.set_tag(ExifTag::GPSAltitudeRef(vec![0]));
    metadata.set_tag(ExifTag::GPSAltitude(vec![ur64(333, 1)]));

    let mut bytes = vec![
        0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01, 0x01, 0x00, 0x00,
        0x01, 0x00, 0x01, 0x00, 0x00, 0xFF, 0xD9,
    ];
    metadata
        .write_to_vec(&mut bytes, little_exif::filetype::FileExtension::JPEG)
        .expect("write conflicting source EXIF into minimal JPEG");
    bytes
}

/// Minimal little-endian TIFF carrying the standard source GPS EXIF.
#[cfg(feature = "xmp")]
pub fn minimal_tiff_with_source_gps() -> Vec<u8> {
    minimal_tiff_with_source_gps_endian(false)
}

/// Minimal big-endian TIFF carrying the standard source GPS EXIF.
#[cfg(feature = "xmp")]
pub fn minimal_big_endian_tiff_with_source_gps() -> Vec<u8> {
    minimal_tiff_with_source_gps_endian(true)
}

#[cfg(feature = "xmp")]
fn minimal_tiff_with_source_gps_endian(big_endian: bool) -> Vec<u8> {
    fn push_u16(bytes: &mut Vec<u8>, value: u16, big_endian: bool) {
        let encoded = if big_endian {
            value.to_be_bytes()
        } else {
            value.to_le_bytes()
        };
        bytes.extend_from_slice(&encoded);
    }

    fn push_u32(bytes: &mut Vec<u8>, value: u32, big_endian: bool) {
        let encoded = if big_endian {
            value.to_be_bytes()
        } else {
            value.to_le_bytes()
        };
        bytes.extend_from_slice(&encoded);
    }

    fn push_ifd_entry(
        bytes: &mut Vec<u8>,
        tag: u16,
        value_type: u16,
        count: u32,
        value: u32,
        big_endian: bool,
    ) {
        push_u16(bytes, tag, big_endian);
        push_u16(bytes, value_type, big_endian);
        push_u32(bytes, count, big_endian);
        push_u32(bytes, value, big_endian);
    }

    const IFD0_OFFSET: u32 = 8;
    const GPS_IFD_OFFSET: u32 = 26;
    const GPS_DATE_OFFSET: u32 = 92;
    const GPS_TIME_OFFSET: u32 = 104;
    const GPS_SPEED_OFFSET: u32 = 128;
    const GPS_ERROR_OFFSET: u32 = 136;

    let mut bytes = Vec::with_capacity(144);
    bytes.extend_from_slice(if big_endian { b"MM" } else { b"II" });
    push_u16(&mut bytes, 42, big_endian);
    push_u32(&mut bytes, IFD0_OFFSET, big_endian);

    push_u16(&mut bytes, 1, big_endian);
    push_ifd_entry(&mut bytes, 0x8825, 4, 1, GPS_IFD_OFFSET, big_endian);
    push_u32(&mut bytes, 0, big_endian);

    push_u16(&mut bytes, 5, big_endian);
    push_ifd_entry(&mut bytes, 0x0007, 5, 3, GPS_TIME_OFFSET, big_endian);
    push_ifd_entry(
        &mut bytes,
        0x000C,
        2,
        2,
        if big_endian {
            u32::from_be_bytes([b'K', 0, 0, 0])
        } else {
            u32::from_le_bytes([b'K', 0, 0, 0])
        },
        big_endian,
    );
    push_ifd_entry(&mut bytes, 0x000D, 5, 1, GPS_SPEED_OFFSET, big_endian);
    push_ifd_entry(&mut bytes, 0x001D, 2, 11, GPS_DATE_OFFSET, big_endian);
    push_ifd_entry(&mut bytes, 0x001F, 5, 1, GPS_ERROR_OFFSET, big_endian);
    push_u32(&mut bytes, 0, big_endian);

    bytes.extend_from_slice(b"2024:06:15\0");
    bytes.push(0);
    for (numerator, denominator) in [(10, 1), (20, 1), (30_125, 1_000)] {
        push_u32(&mut bytes, numerator, big_endian);
        push_u32(&mut bytes, denominator, big_endian);
    }
    push_u32(&mut bytes, 12_345, big_endian);
    push_u32(&mut bytes, 100, big_endian);
    push_u32(&mut bytes, 3, big_endian);
    push_u32(&mut bytes, 2, big_endian);

    debug_assert_eq!(bytes.len(), 144);
    bytes
}

/// Structurally valid `ftyp`-only HEIC with no `meta` box, so it carries no
/// EXIF item. Readers must treat it as media with no source GPS, not an error.
#[cfg(feature = "xmp")]
pub fn heif_ftyp_without_meta_bytes() -> Vec<u8> {
    vec![
        0, 0, 0, 24, b'f', b't', b'y', b'p', b'h', b'e', b'i', b'c', 0, 0, 0, 0, b'h', b'e', b'i',
        b'c', b'm', b'i', b'f', b'1',
    ]
}

/// Asserts the standard source GPS time and speed, without accuracy when
/// native coordinates are absent or differ from the provider location.
#[cfg(feature = "xmp")]
pub fn assert_source_gps_in_xmp(meta: &xmp_toolkit::XmpMeta) {
    use xmp_toolkit::xmp_ns;

    let text = |name: &str| meta.property(xmp_ns::EXIF, name).expect(name).value;
    assert_eq!(text("GPSTimeStamp"), SOURCE_GPS_DATETIME);
    assert_eq!(text("GPSSpeedRef"), SOURCE_GPS_SPEED_REF);
    assert_eq!(text("GPSSpeed"), SOURCE_GPS_SPEED);
    assert!(!meta.contains_property(xmp_ns::EXIF, "GPSHPositioningError"));
    assert!(
        !meta.contains_property("http://cipa.jp/exif/1.0/", "GPSHPositioningError"),
        "Apple-compatible sidecars must not duplicate GPSHPositioningError in exifEX"
    );
}

/// Dedicated Linux subprocess fixture rendezvous; absent from release builds.
/// The parent uses Child::kill and verifies SIGKILL, so no Rust unwinding or
/// cooperative cancellation can complete the paused transition.
#[cfg(target_os = "linux")]
pub(crate) fn process_death_point(point: &str) {
    if std::env::var("KEI_TEST_PROCESS_DEATH_POINT").as_deref() != Ok(point) {
        return;
    }
    let root = std::path::PathBuf::from(
        std::env::var_os("KEI_TEST_PROCESS_DEATH_ROOT").expect("dedicated fixture root"),
    );
    assert_eq!(
        std::fs::read(root.join("fixture-owner")).unwrap(),
        b"kei-synthetic-process-death"
    );
    // Publish the rendezvous atomically so the parent cannot read an empty
    // marker between creation and write. This is process synchronization only.
    std::fs::write(root.join("ready.tmp"), point).unwrap();
    std::fs::rename(root.join("ready.tmp"), root.join("ready")).unwrap();
    loop {
        std::thread::park();
    }
}

#[cfg(target_os = "linux")]
pub(crate) fn process_death_force_journal() -> bool {
    std::env::var("KEI_TEST_PROCESS_DEATH_POINT").is_ok_and(|point| {
        matches!(
            point.as_str(),
            "journal-displaced" | "journal-installed" | "journal-committed"
        )
    })
}

// A path-scoped, one-shot seam for the retained-source proof boundary. Other
// parallel fixtures cannot consume it, and the guard removes unused hooks.
struct PrimarySourceMutation {
    path: std::path::PathBuf,
    bytes: Vec<u8>,
    replace_inode: bool,
    fired: Arc<std::sync::atomic::AtomicBool>,
}
static PRIMARY_SOURCE_MUTATIONS: std::sync::Mutex<Vec<PrimarySourceMutation>> =
    std::sync::Mutex::new(Vec::new());
pub(crate) struct PrimarySourceMutationGuard {
    path: std::path::PathBuf,
    fired: Arc<std::sync::atomic::AtomicBool>,
}
impl PrimarySourceMutationGuard {
    pub(crate) fn fired(&self) -> bool {
        self.fired.load(std::sync::atomic::Ordering::SeqCst)
    }
}
impl Drop for PrimarySourceMutationGuard {
    fn drop(&mut self) {
        PRIMARY_SOURCE_MUTATIONS
            .lock()
            .unwrap()
            .retain(|hook| hook.path != self.path);
    }
}
pub(crate) fn primary_source_mutation(
    path: std::path::PathBuf,
    bytes: Vec<u8>,
    replace_inode: bool,
) -> PrimarySourceMutationGuard {
    let fired = Arc::new(std::sync::atomic::AtomicBool::new(false));
    let mut hooks = PRIMARY_SOURCE_MUTATIONS.lock().unwrap();
    assert!(!hooks.iter().any(|hook| hook.path == path));
    hooks.push(PrimarySourceMutation {
        path: path.clone(),
        bytes,
        replace_inode,
        fired: fired.clone(),
    });
    PrimarySourceMutationGuard { path, fired }
}
pub(crate) async fn primary_source_snapshot_point(path: &std::path::Path) -> anyhow::Result<()> {
    let hook = {
        let mut hooks = PRIMARY_SOURCE_MUTATIONS.lock().unwrap();
        hooks
            .iter()
            .position(|hook| hook.path == path)
            .map(|index| hooks.remove(index))
    };
    if let Some(hook) = hook {
        if hook.replace_inode {
            let replacement = path.with_extension("primary-snapshot-test");
            tokio::fs::write(&replacement, &hook.bytes).await?;
            tokio::fs::rename(replacement, path).await?;
        } else {
            tokio::fs::write(path, &hook.bytes).await?;
        }
        hook.fired.store(true, std::sync::atomic::Ordering::SeqCst);
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[tokio::test]
    async fn mock_session_records_calls_and_sequences_responses() {
        let mock = MockPhotosSession::new()
            .ok(json!({"records": [{"id": 1}]}))
            .err("server error");

        assert_eq!(mock.call_count(), 0);

        let r1 = mock
            .post("https://example.com/query", "{}".to_owned(), &[])
            .await;
        assert!(r1.is_ok());
        assert_eq!(mock.call_count(), 1);

        let r2 = mock
            .post("https://example.com/changes", "{}".to_owned(), &[])
            .await;
        assert!(r2.is_err());
        assert_eq!(mock.call_count(), 2);

        // Exhausted queue falls back to empty records
        let r3 = mock
            .post("https://example.com/extra", "{}".to_owned(), &[])
            .await;
        assert_eq!(r3.unwrap(), json!({"records": []}));

        let calls = mock.recorded_calls();
        assert_eq!(calls.len(), 3);
        assert_eq!(calls[0].url, "https://example.com/query");
        assert_eq!(calls[1].url, "https://example.com/changes");
    }

    #[tokio::test]
    async fn mock_photos_flow_queues_common_cloudkit_shapes() {
        let mock = MockPhotosFlow::new()
            .album_count(7)
            .changes_database("db-token", &[("PrimarySync", "zone-token")], true)
            .error("transport failure")
            .build();

        let count = mock
            .post("https://example.com/count", "{}".to_owned(), &[])
            .await
            .expect("count response");
        assert_eq!(
            count["batch"][0]["records"][0]["fields"]["itemCount"]["value"],
            7
        );

        let changes = mock
            .post("https://example.com/changes/database", "{}".to_owned(), &[])
            .await
            .expect("changes database response");
        assert_eq!(changes["syncToken"], "db-token");
        assert_eq!(changes["zones"][0]["zoneID"]["zoneName"], "PrimarySync");
        assert_eq!(changes["zones"][0]["syncToken"], "zone-token");
        assert!(changes["moreComing"].as_bool().unwrap_or(false));

        let err = mock
            .post("https://example.com/fail", "{}".to_owned(), &[])
            .await
            .expect_err("queued transport error");
        assert!(err.to_string().contains("transport failure"));
    }
}