From 170202d1c64887e7afad8547d7cd6e3e4447fcb7 Mon Sep 17 00:00:00 2001 From: t Date: Wed, 16 Sep 2026 15:52:16 +0530 Subject: [PATCH] Move the inline tests out of commands.rs and sync/reconciliation.rs docs/proposed-rust-module-conventions.md item 2, continuing #440. Four inline test modules move to files beside their parents, each kept as the same child module via #[path], so private access, test paths and module paths are unchanged: commands.rs tests -> commands_tests.rs statement_export_tests -> commands_statement_export_tests.rs party_statement_export_tests -> commands_party_statement_export_tests.rs sync/reconciliation.rs tests -> reconciliation_tests.rs commands.rs has production code after its first test block; it stays in place. commands.rs goes from 4,684 to 3,580 lines; reconciliation.rs from 2,578 to 1,708. Checked, not asserted: - Both parents rebuild byte-identical to HEAD from the new parent plus the re-indented children, expanding only the three moved commands.rs modules (the existing commands_native_ledger_tests.rs declaration is untouched). - rustfmt changed layout only: two trailing commas in commands_tests.rs, and in reconciliation_tests.rs two match arms `=> { assignment }` rewritten as `=> assignment,`, which both evaluate to (). The 559 string literals in those two reformatted files are identical; the other two test files and both parents were unchanged by rustfmt. - Whole-lib test lists are identical before and after under default (968), voucher-scan (986), live-calibration-harness (988) and lab-writes (1,068). The 67 moved tests pass. The only compiler warnings are the two existing unused variables in runtime.rs under voucher-scan. - The two #[cfg(unix)] tests moved; unix includes the macOS host. The target_os cfgs in commands.rs are in production code that did not move. - Text readers checked: scripts/client-grouping.test.mjs and ledger-entries-ui.test.mjs read commands.rs and still pass, as does the full frontend suite (239). check-tally-live-read-boundary.mjs scans reconciliation.rs for probe features and still passes. - clippy --lib --tests -D warnings is clean. Resealing changed exactly two pinned hashes; the coverage report counts four newly unsealed test-only modules and lists nothing else. The four new test files are unpinned, like #440's: tests decide nothing about what Bridge posts or emits, so they carry no pin reason. Co-Authored-By: Claude Opus 5 --- .../compatibility/compatibility-matrix.json | 2 +- .../compatibility/compatibility-surface.json | 6 +- src-tauri/src/commands.rs | 1116 +---------------- .../commands_party_statement_export_tests.rs | 314 +++++ .../src/commands_statement_export_tests.rs | 31 + src-tauri/src/commands_tests.rs | 754 +++++++++++ src-tauri/src/sync/reconciliation.rs | 874 +------------ src-tauri/src/sync/reconciliation_tests.rs | 865 +++++++++++++ 8 files changed, 1976 insertions(+), 1986 deletions(-) create mode 100644 src-tauri/src/commands_party_statement_export_tests.rs create mode 100644 src-tauri/src/commands_statement_export_tests.rs create mode 100644 src-tauri/src/commands_tests.rs create mode 100644 src-tauri/src/sync/reconciliation_tests.rs diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json index 0728fc12..a0d546b2 100644 --- a/docs/tally/compatibility/compatibility-matrix.json +++ b/docs/tally/compatibility/compatibility-matrix.json @@ -1,7 +1,7 @@ { "schema_version": 1, "bridge_commit_sha": "be1c20cc3fd66fa1ece196505c69f26e555e4b8e", - "compatibility_surface_sha256": "d1f7d3d265c7bda1f97b03db15220fd8de9d195cfd7cf34c0466e879cd580db6", + "compatibility_surface_sha256": "17839f51bf0cb7ef920f1b3d2fc12b941279422701a0dc724a2322d79f9a4a6e", "claims": [ { "claim_id": "erp9-6-6-3-windows-education-xml-one-company", diff --git a/docs/tally/compatibility/compatibility-surface.json b/docs/tally/compatibility/compatibility-surface.json index 6a3f44ec..fd9e0c56 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -467,7 +467,7 @@ }, { "path": "src-tauri/src/commands.rs", - "sha256": "ab5e6dbaf387a239ac20fa7cdeec0785060ca7eb4dda9443c6c2e44c952dc5a7" + "sha256": "7a9ca27a59c6fddee2c9dfa131eb46170eba76811bc4e625d55028413eee611d" }, { "path": "src-tauri/src/commands_trial_balance.rs", @@ -707,7 +707,7 @@ }, { "path": "src-tauri/src/sync/reconciliation.rs", - "sha256": "878b41be6ce720667ae76e25c6be3efc8a4083e4ed4982885b78d1b21b0876ac" + "sha256": "7094862f2cec5072226d6ffa0dd7a0aa8828b002c0d438b7874a2a54de59b963" }, { "path": "src-tauri/src/sync/snapshot.rs", @@ -958,5 +958,5 @@ "sha256": "a8ac2714fecf51947f2822c8c46d7ce2e8602c732780ff60566a7771f0836f9a" } ], - "manifest_sha256": "d1f7d3d265c7bda1f97b03db15220fd8de9d195cfd7cf34c0466e879cd580db6" + "manifest_sha256": "17839f51bf0cb7ef920f1b3d2fc12b941279422701a0dc724a2322d79f9a4a6e" } \ No newline at end of file diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index a62f65b0..e21abe6f 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -2998,767 +2998,8 @@ pub async fn select_document_folder() -> Result(serde_json::json!({ - "config": { "host": "127.0.0.1", "port": 9000 }, - "selected_company": { - "display_name": "Synthetic Company", - "company_guid": "synthetic-guid", - "company_number": "100001", - "books_from_yyyymmdd": "20260401" - }, - "currency_assertion": "USD" - })); - assert!( - rejected.is_err(), - "unsupported currencies must not start a scan" - ); - } - - #[test] - fn company_sweep_keeps_probe_and_read_failures_in_band() { - let outcomes = [ - Ok(OutstandingsLoadResult::Partial { - reason: crate::tally::OutstandingsPartialReason::code("first_book_partial"), - synced_at_unix_ms: 1, - }), - Err(CompanySweepFailure::ReasonCode( - "company_currency_probe_failed", - )), - Err(CompanySweepFailure::ReasonCode( - "company_base_currency_undetermined", - )), - Err(CompanySweepFailure::ReasonCode( - "company_outstandings_read_failed", - )), - Ok(OutstandingsLoadResult::Partial { - reason: crate::tally::OutstandingsPartialReason::code("last_book_partial"), - synced_at_unix_ms: 2, - }), - ] - .into_iter() - .map(company_sweep_result) - .collect::>(); - - assert_eq!( - outcomes.len(), - 5, - "one bad book must not truncate the sweep" - ); - assert!(matches!( - &outcomes[1], - OutstandingsLoadResult::Partial { reason, .. } - if reason.reason_code == "company_currency_probe_failed" - )); - assert!(matches!( - &outcomes[2], - OutstandingsLoadResult::Partial { reason, .. } - if reason.reason_code == "company_base_currency_undetermined" - )); - assert!(matches!( - &outcomes[3], - OutstandingsLoadResult::Partial { reason, .. } - if reason.reason_code == "company_outstandings_read_failed" - )); - assert!(matches!( - &outcomes[4], - OutstandingsLoadResult::Partial { reason, .. } - if reason.reason_code == "last_book_partial" - )); - } - - #[test] - fn company_sweep_preserves_a_company_listing_transport_reason() { - let result = company_sweep_result(Err(CompanySweepFailure::CompanyVerification( - tally_command_error( - "endpoint_unreachable", - "Endpoint configuration", - "Synthetic transport failure", - "after_change", - false, - "Restore connectivity.", - ), - ))); - - assert!(matches!( - result, - OutstandingsLoadResult::Partial { reason, .. } - if reason.reason_code == "endpoint_unreachable" - )); - } - - #[test] - fn company_sweep_currency_preflight_names_undetermined_base_currency() { - assert_eq!( - company_sweep_currency_preflight_failure(2, false), - Some("company_base_currency_undetermined"), - "several currency masters do not identify the company's base currency" - ); - assert_eq!( - company_sweep_currency_preflight_failure(2, true), - Some("company_base_currency_undetermined"), - "the parser's INR flag is not authoritative when several currency masters exist" - ); - assert_eq!( - company_sweep_currency_preflight_failure(1, false), - Some("company_base_currency_not_inr"), - "one non-Indian currency identifies an unsupported base currency" - ); - assert_eq!(company_sweep_currency_preflight_failure(1, true), None); - assert_eq!( - establish_inr_currency(1, true), - Ok(OutstandingsCurrencyAssertion::Inr), - "the backend boundary receives a typed INR admission only after the probe established one INR master" - ); - assert_eq!( - company_sweep_currency_preflight_failure(0, false), - Some("company_currency_probe_failed"), - "an impossible empty collection remains fail-closed" - ); - } - - #[test] - fn company_sweep_preserves_runtime_foreign_currency_partial() { - let outcome = company_sweep_result(Ok(OutstandingsLoadResult::Partial { - reason: crate::tally::OutstandingsPartialReason::foreign_currency_ledger_balance( - "Synthetic FX Debtor".to_string(), - ), - synced_at_unix_ms: 1, - })); - - assert!(matches!( - outcome, - OutstandingsLoadResult::Partial { reason, .. } - if reason.reason_code == "company_foreign_currency_ledger_balance" - && reason.foreign_currency_ledger_name.as_deref() == Some("Synthetic FX Debtor") - )); - } - - #[test] - fn export_names_are_portable_and_reserved_devices_are_neutralized() { - assert_eq!( - portable_export_file_name("outstandings-A:B?C.csv").unwrap(), - "outstandings-A-B-C.csv" - ); - assert_eq!(portable_export_file_name("CON.csv").unwrap(), "_CON.csv"); - assert!(portable_export_file_name("../outside.csv").is_err()); - assert!(portable_export_file_name("nested/report.csv").is_err()); - } - - #[test] - fn report_downloads_never_overwrite_an_existing_file() { - let directory = tempfile::tempdir().expect("temporary download directory"); - let first = write_unique_download(directory.path(), "report.csv", b"first").unwrap(); - let second = write_unique_download(directory.path(), "report.csv", b"second").unwrap(); - - assert_eq!(first.file_name().unwrap(), "report.csv"); - assert_eq!(second.file_name().unwrap(), "report-2.csv"); - assert_eq!(std::fs::read(first).unwrap(), b"first"); - assert_eq!(std::fs::read(second).unwrap(), b"second"); - } - - #[test] - fn snapshot_capability_canary_is_exactly_the_requested_first_calendar_day() { - let canary = first_calendar_day_canary_window("20260228").unwrap(); - assert_eq!(canary.range.from_yyyymmdd, "20260228"); - assert_eq!(canary.range.to_yyyymmdd, "20260228"); - assert_eq!(canary.query_profile.as_str(), "core_accounting_v3"); - - let same = first_calendar_day_canary_window("20260228").unwrap(); - assert_eq!(same, canary); - assert!(first_calendar_day_canary_window("20260229").is_err()); - assert!(first_calendar_day_canary_window("2026-02-28").is_err()); - } - - #[test] - fn tally_runtime_error_serialization_is_stable_and_redacted() { - let error = tally_runtime_command_error(anyhow::anyhow!( - "synthetic reqwest failure at http://127.0.0.1:9000/?token=private" - )); - let json = serde_json::to_string(&error).expect("serialize safe Tally command error"); - assert_eq!(error.code, "endpoint_unreachable"); - assert_eq!(error.category, "Endpoint configuration"); - assert!(error.local_state_changed); - assert!(!error.tally_state_may_have_changed); - assert!(!json.contains("token=private")); - assert!(!json.contains("reqwest")); - - let invalid_config = - tally_runtime_command_error(anyhow::anyhow!("Tally port must be between 1 and 65535")); - assert_eq!(invalid_config.code, "endpoint_configuration_invalid"); - assert!(!invalid_config.local_state_changed); - - let queue_deadline = - tally_runtime_command_error(anyhow::anyhow!("endpoint queue deadline exceeded")); - assert_eq!(queue_deadline.code, "tally_runtime_temporarily_unavailable"); - assert!(queue_deadline.local_state_changed); - - let deadline = tally_runtime_command_error( - anyhow::Error::new(bridge_tally_transport::TallyTransportError::RequestTimedOut) - .context("outstandings second segment read failed for 20251002..20251101"), - ); - assert_eq!(deadline.code, "tally_request_deadline_exceeded"); - assert_eq!(deadline.retry, "after_change"); - assert!(deadline.remediation.contains("Do not retry")); - - let discovery_limit = tally_runtime_command_error(anyhow::anyhow!( - "interactive discovery listing limit exceeded: synthetic company response" - )); - assert_eq!(discovery_limit.code, "untrusted_discovery_limit_exceeded"); - assert_eq!(discovery_limit.category, "Discovery listing"); - } - - #[test] - fn opening_balance_source_disagreement_is_response_validation_not_endpoint_failure() { - let error = tally_runtime_command_error(anyhow::Error::new( - crate::tally::connection::PartyLedgerMasterSourceValidationError::OpeningBalancesDisagreed, - )); - - assert_eq!(error.code, "response_validation_failed"); - assert_eq!(error.category, "Response validation"); - assert_ne!(error.code, "endpoint_unreachable"); - } - - #[test] - fn every_party_master_source_validation_is_not_an_endpoint_failure() { - use crate::tally::connection::PartyLedgerMasterSourceValidationError as Validation; - - for error in [ - Validation::MasterPeriod, - Validation::BalancePeriod, - Validation::MasterGuid, - Validation::MasterId, - Validation::MasterAlterId, - Validation::MasterOpeningBalance, - Validation::DuplicateMasterIdentity, - Validation::BalanceMissingMasterLedger, - Validation::OpeningBalancesDisagreed, - Validation::BalanceLedgerAbsentFromMasterEvidence, - Validation::DuplicateBalanceDisplayKey, - Validation::BalanceCompanyIdentityUnverified, - Validation::GroupCompanyIdentityUnverified, - Validation::MasterResponseInvalid { - source: anyhow::anyhow!("synthetic parser failure"), - }, - ] { - let mapped = tally_runtime_command_error(anyhow::Error::new(error)); - assert_eq!(mapped.code, "response_validation_failed"); - assert_eq!(mapped.category, "Response validation"); - assert_ne!(mapped.code, "endpoint_unreachable"); - } - } - - #[test] - fn retained_wire_evidence_does_not_hide_typed_command_refusals() { - use crate::tally::runtime::{with_read_evidence, RuntimeReadEvidence}; - for (source, code) in [ - (anyhow::Error::new(crate::tally::runtime::OpeningBoundaryObservationError::Unobserved), "response_validation_failed"), - (anyhow::Error::new(crate::tally::runtime::OpeningBoundaryObservationError::Changed), "response_validation_failed"), - (anyhow::Error::new(crate::tally::connection::PartyLedgerMasterSourceValidationError::OpeningBalancesDisagreed), "response_validation_failed"), - (anyhow::Error::new(crate::tally::connection::PairedReadValidationError::PartyLedgerMaster), "response_validation_failed"), - (anyhow::Error::new(crate::tally::runtime::TallyRuntimeControlError::QueueDeadline), "tally_runtime_temporarily_unavailable"), - ] { - let wrapped = with_read_evidence(source, RuntimeReadEvidence::empty()); - let mapped = tally_runtime_command_error(wrapped); - assert_eq!(mapped.code, code); - assert!(!mapped.message.contains("opening balances disagreed")); - } - } - - #[test] - fn drifted_party_master_read_is_response_validation_not_endpoint_failure() { - let error = tally_runtime_command_error(anyhow::Error::new( - crate::tally::connection::PairedReadValidationError::PartyLedgerMaster, - )); - - assert_eq!(error.code, "response_validation_failed"); - assert_eq!(error.category, "Response validation"); - assert_ne!(error.code, "endpoint_unreachable"); - } - - #[test] - fn party_master_export_keeps_runtime_control_error_code_and_hides_runtime_detail() { - let error = party_ledger_master_runtime_command_error(anyhow::Error::new( - crate::tally::runtime::TallyRuntimeControlError::QueueDeadline, - )); - - assert_eq!(error.code, "tally_runtime_temporarily_unavailable"); - assert_eq!(error.retry, "safe"); - assert!(error.local_state_changed); - assert!(error - .message - .starts_with("Bridge withheld the party/ledger master:")); - assert!(!error.message.contains("QueueDeadline")); - } - - #[test] - fn party_master_currency_admission_does_not_misdiagnose_multiple_masters() { - let error = - party_ledger_master_currency_admission_error("company_base_currency_undetermined"); - - assert_eq!(error.code, "company_base_currency_undetermined"); - assert!(error.message.contains("multiple Currency masters")); - assert!(error - .message - .contains("could not establish the selected company's base currency from this read")); - assert!(!error.message.contains("more than one base currency")); - assert!(error - .remediation - .contains("no operator confirmation can make this read safe")); - assert!(error - .remediation - .contains("Do not retry the unchanged export")); - } - - #[test] - fn party_master_currency_admission_does_not_direct_an_inr_retry_for_non_inr_company() { - let error = party_ledger_master_currency_admission_error("company_base_currency_not_inr"); - - assert_eq!(error.code, "company_base_currency_not_inr"); - assert!(error.remediation.contains("select a company")); - assert!(error - .remediation - .contains("A confirmation cannot change an unsupported base currency")); - } - - #[test] - fn shared_ledger_command_result_keeps_legacy_nullable_observation_json() { - let result = vec![ - TallyLedger { - name: "Returned ledger".to_string(), - parent: PartyLedgerMasterFieldObservation::Returned("Sundry Debtors".to_string()), - party_gstin: PartyLedgerMasterFieldObservation::Returned( - "29ABCDE1234F1Z5".to_string(), - ), - opening_balance: Some("0".to_string()), - }, - TallyLedger { - name: "Empty ledger".to_string(), - parent: PartyLedgerMasterFieldObservation::Returned(String::new()), - party_gstin: PartyLedgerMasterFieldObservation::Returned(String::new()), - opening_balance: None, - }, - TallyLedger { - name: "Unobserved ledger".to_string(), - parent: PartyLedgerMasterFieldObservation::NotObserved, - party_gstin: PartyLedgerMasterFieldObservation::NotObserved, - opening_balance: None, - }, - ]; - - assert_eq!( - serde_json::to_value(result).expect("command result serializes"), - serde_json::json!([ - { - "name": "Returned ledger", - "parent": "Sundry Debtors", - "party_gstin": "29ABCDE1234F1Z5", - "opening_balance": "0", - }, - { - "name": "Empty ledger", - "parent": "", - "party_gstin": "", - "opening_balance": null, - }, - { - "name": "Unobserved ledger", - "parent": null, - "party_gstin": null, - "opening_balance": null, - }, - ]) - ); - } - - #[test] - fn explicit_tally_error_preserves_atomic_failure_truth() { - let error = tally_command_error( - "reviewed_setup_store_failed", - "Operation", - "Synthetic reviewed setup was not stored", - "after_change", - false, - "Inspect local encrypted storage.", - ); - assert_eq!(error.code, "reviewed_setup_store_failed"); - assert!(!error.local_state_changed); - assert!(!error.tally_state_may_have_changed); - } - - #[test] - fn post_commit_review_cleanup_failure_preserves_durable_success_truth() { - let saved = SavedTallySetup { - passport_snapshot_id: "snapshot-1".to_string(), - canonical_origin: "http://127.0.0.1:9000".to_string(), - observed_at_unix_ms: 1_000, - company: PersistedTallyCompany { - name: "Synthetic Company".to_string(), - guid: Some("synthetic-guid".to_string()), - company_number: Some("100001".to_string()), - books_from_yyyymmdd: Some("20260401".to_string()), - mirror_company_id: Some("company-1".to_string()), - correlation_key: Some("c".repeat(64)), - identity_confidence: "observed", - }, - review_cleanup_warning: None, - }; - let result = reconcile_review_cleanup(Ok(saved), false).expect("save stays successful"); - assert_eq!( - result.review_cleanup_warning, - Some("review_cache_cleanup_failed_after_save") - ); - - let failed = reconcile_review_cleanup( - Err(tally_command_error( - "reviewed_setup_store_failed", - "Operation", - "Synthetic failure", - "after_change", - false, - "Retry.", - )), - false, - ) - .expect_err("failed store plus failed cleanup is explicit"); - assert_eq!(failed.code, "reviewed_setup_retry_state_uncertain"); - assert!(failed.local_state_changed); - } - - #[test] - fn reviewed_probe_commitment_binds_time_company_name_and_full_company_list() { - let probe = |names: &[&str]| TallyProbeResult { - connection: ConnectionStatus { - reachable: true, - compatible: false, - server_text: "Synthetic status".to_string(), - product: TallyProduct::Unknown, - error: None, - }, - companies: names - .iter() - .enumerate() - .map(|(index, name)| TallyCompany { - name: (*name).to_string(), - guid: Some(format!("guid-{index}")), - company_number: Some(format!("1000{index}")), - books_from: Some("20260401".to_string()), - }) - .collect(), - profile: CapabilityProfile { - profile_version: 2, - product: "Unknown".to_string(), - release: None, - license_tier: None, - mode: None, - transports: BTreeMap::new(), - features: BTreeMap::new(), - packs: BTreeMap::new(), - }, - selected_read_scope: None, - passport_snapshot_id: None, - }; - let first = reviewed_probe_commitment_sha256( - "review-a", - "http://127.0.0.1:9000", - 1_000, - &probe(&["Synthetic A"]), - ) - .unwrap(); - let renamed = reviewed_probe_commitment_sha256( - "review-a", - "http://127.0.0.1:9000", - 1_000, - &probe(&["Synthetic Renamed"]), - ) - .unwrap(); - let expanded = reviewed_probe_commitment_sha256( - "review-a", - "http://127.0.0.1:9000", - 1_000, - &probe(&["Synthetic A", "Synthetic B"]), - ) - .unwrap(); - let later = reviewed_probe_commitment_sha256( - "review-a", - "http://127.0.0.1:9000", - 1_001, - &probe(&["Synthetic A"]), - ) - .unwrap(); - assert_ne!(first, renamed); - assert_ne!(first, expanded); - assert_ne!(first, later); - let different_review = reviewed_probe_commitment_sha256( - "review-b", - "http://127.0.0.1:9000", - 1_000, - &probe(&["Synthetic A"]), - ) - .unwrap(); - assert_ne!(first, different_review); - } - - #[test] - fn selected_read_observation_distinguishes_empty_identity_evidence() { - let observation = |bucket| SelectedReadObservation { - request_sha256: "a".repeat(64), - decoded_response_sha256: "b".repeat(64), - response_encoding: "utf8", - result_bucket: bucket, - }; - let empty = selected_read_observation( - "selected_ledger_read", - Ok(observation("empty_observed")), - false, - "selected_ledger_read_empty_observed", - "selected_ledger_read_non_empty_observed", - ); - assert_eq!(empty.identity_evidence_state, "not_applicable_empty"); - assert!(empty.record_count_verified); - - let populated = selected_read_observation( - "selected_ledger_read", - Ok(observation("non_empty_observed")), - false, - "selected_ledger_read_empty_observed", - "selected_ledger_read_non_empty_observed", - ); - assert_eq!(populated.identity_evidence_state, "verified"); - } -} +#[path = "commands_tests.rs"] +mod tests; fn portable_export_file_name(file_name: &str) -> Result { let trimmed = file_name.trim(); @@ -4311,357 +3552,12 @@ fn statement_filename_slug(party: &str) -> String { } #[cfg(test)] -mod statement_export_tests { - use super::*; - - #[test] - fn repeated_statement_exports_get_distinct_new_files() { - let destination = tempfile::tempdir().expect("synthetic destination"); - let first = write_unique_statement_file( - destination.path(), - "statement-party-20260809", - "xlsx", - b"one", - ) - .expect("first statement"); - let second = write_unique_statement_file( - destination.path(), - "statement-party-20260809", - "xlsx", - b"two", - ) - .expect("second statement"); - - assert_eq!( - first.file_name().and_then(|name| name.to_str()), - Some("statement-party-20260809.xlsx") - ); - assert_eq!( - second.file_name().and_then(|name| name.to_str()), - Some("statement-party-20260809-2.xlsx") - ); - assert_eq!(std::fs::read(first).expect("first bytes"), b"one"); - assert_eq!(std::fs::read(second).expect("second bytes"), b"two"); - } -} +#[path = "commands_statement_export_tests.rs"] +mod statement_export_tests; #[cfg(test)] -mod party_statement_export_tests { - use super::*; - - #[test] - fn utf8_picker_destination_round_trips_to_the_authorized_path() { - let destination = tempfile::tempdir().expect("temporary destination"); - let selected_path = destination.path().to_path_buf(); - let approvals = PartyStatementDestinationApprovals::default(); - let approval_id = approvals - .issue(selected_path.clone()) - .expect("approve picker destination"); - let ipc_destination = - require_utf8_destination(selected_path).expect("temporary destination is valid UTF-8"); - - let approved = approvals - .consume(&approval_id, std::path::Path::new(&ipc_destination)) - .expect("IPC path reconstruction retains the selected destination"); - - assert_eq!(approved.path(), std::path::Path::new(&ipc_destination)); - } - - fn bulk_export_request( - destination: &std::path::Path, - approval_id: &str, - ) -> ExportBulkPartyStatementsRequest { - ExportBulkPartyStatementsRequest { - company: "Synthetic Books Pvt Ltd".to_string(), - as_of_yyyymmdd: "20260808".to_string(), - format: PartyStatementFormat::Xlsx, - ageing_anchor: crate::tally::OutstandingsAgeingAnchor::DueDate, - destination: destination.to_string_lossy().into_owned(), - approval_id: approval_id.to_string(), - open_bills: vec![OpenBillRow { - party: "Synthetic Party".to_string(), - reference: "SYNTHETIC-1".to_string(), - bill_date: "20260801".to_string(), - due_date: "20260831".to_string(), - amount: bridge_tally_core::ExactDecimal::parse("100.00").expect("synthetic amount"), - age_days: Some(7), - kind: crate::tally::ExposureDirection::Receivable, - }], - unallocated_by_party: Vec::new(), - } - } - - #[test] - fn statement_export_format_defaults_to_xlsx_and_accepts_pdf() { - let base = serde_json::json!({ - "company": "Synthetic Books Pvt Ltd", - "as_of_yyyymmdd": "20260808", - "party": "Synthetic Party", - "open_bills": [], - "unallocated_by_party": [], - }); - let defaulted: ExportPartyStatementRequest = serde_json::from_value(base.clone()).unwrap(); - assert!(matches!(defaulted.format, PartyStatementFormat::Xlsx)); - assert!(matches!( - defaulted.ageing_anchor, - crate::tally::OutstandingsAgeingAnchor::DueDate - )); - - let mut pdf = base; - pdf["format"] = serde_json::Value::String("pdf".to_string()); - let pdf: ExportPartyStatementRequest = serde_json::from_value(pdf).unwrap(); - assert!(matches!(pdf.format, PartyStatementFormat::Pdf)); - } - - #[test] - fn bulk_statement_export_defaults_the_ageing_anchor_for_legacy_callers() { - let request: ExportBulkPartyStatementsRequest = serde_json::from_value(serde_json::json!({ - "company": "Synthetic Books Pvt Ltd", - "as_of_yyyymmdd": "20260808", - "format": "xlsx", - "destination": "/tmp/statements", - "approval_id": "synthetic-approval", - "open_bills": [], - "unallocated_by_party": [], - })) - .unwrap(); - - assert!(matches!( - request.ageing_anchor, - crate::tally::OutstandingsAgeingAnchor::DueDate - )); - } - - #[test] - fn bulk_statement_export_rejects_an_unselected_destination_without_writing() { - let destination = tempfile::tempdir().expect("unselected synthetic destination"); - let approvals = PartyStatementDestinationApprovals::default(); - - let error = export_bulk_party_statements_at_selected_destination( - bulk_export_request(destination.path(), "not-issued"), - &approvals, - ) - .expect_err("an unselected destination must be rejected"); - - match error { - BulkPartyStatementExportError::DestinationNotAuthorized(error) => { - assert_eq!(error.code, "statement_destination_not_authorized"); - assert_eq!(error.retry, "after_change"); - assert!(!error.local_state_changed); - } - BulkPartyStatementExportError::Existing(error) => { - panic!("expected typed destination error, got {error}"); - } - } - assert!( - std::fs::read_dir(destination.path()) - .expect("destination remains readable") - .next() - .is_none(), - "the rejected batch must not write a statement or manifest" - ); - } - - #[test] - fn bulk_statement_export_writes_to_the_destination_approved_by_the_picker() { - let destination = tempfile::tempdir().expect("selected synthetic destination"); - let approvals = PartyStatementDestinationApprovals::default(); - let approval_id = approvals - .issue(destination.path().to_path_buf()) - .expect("record picker destination"); - - let result = export_bulk_party_statements_at_selected_destination( - bulk_export_request(destination.path(), &approval_id), - &approvals, - ) - .expect("recorded destination is accepted"); - - assert_eq!(result.written.len(), 1); - assert!(std::path::Path::new(&result.manifest_path).is_file()); - assert!(destination - .path() - .join(&result.written[0].file_name) - .is_file()); - } - - #[test] - fn independent_picker_approvals_export_to_their_own_destinations() { - let first_destination = tempfile::tempdir().expect("first synthetic destination"); - let second_destination = tempfile::tempdir().expect("second synthetic destination"); - let approvals = PartyStatementDestinationApprovals::default(); - // Both selections exist before either export starts; neither replaces - // the other as the previous singleton store did. - let first_approval = approvals - .issue(first_destination.path().to_path_buf()) - .expect("approve first destination"); - let second_approval = approvals - .issue(second_destination.path().to_path_buf()) - .expect("approve second destination"); - - let first = export_bulk_party_statements_at_selected_destination( - bulk_export_request(first_destination.path(), &first_approval), - &approvals, - ) - .expect("first approved destination writes"); - let second = export_bulk_party_statements_at_selected_destination( - bulk_export_request(second_destination.path(), &second_approval), - &approvals, - ) - .expect("second approved destination writes"); - - assert!(std::path::Path::new(&first.manifest_path).starts_with(first_destination.path())); - assert!(std::path::Path::new(&second.manifest_path).starts_with(second_destination.path())); - assert!(first_destination - .path() - .join(&first.written[0].file_name) - .is_file()); - assert!(second_destination - .path() - .join(&second.written[0].file_name) - .is_file()); - } - - #[test] - fn cancelled_picker_leaves_no_approval_that_can_export() { - let destination = tempfile::tempdir().expect("cancelled synthetic destination"); - let approvals = PartyStatementDestinationApprovals::default(); - - let error = export_bulk_party_statements_at_selected_destination( - bulk_export_request(destination.path(), "no-picker-selection"), - &approvals, - ) - .expect_err("a cancelled picker has no approval to consume"); - - assert!(matches!( - error, - BulkPartyStatementExportError::DestinationNotAuthorized(error) - if error.code == "statement_destination_not_authorized" - )); - assert!( - std::fs::read_dir(destination.path()) - .expect("destination remains readable") - .next() - .is_none(), - "the cancelled selection must not write a statement or manifest" - ); - } - - #[test] - fn approval_destination_mismatch_is_rejected_without_writing() { - let approved_destination = tempfile::tempdir().expect("approved synthetic destination"); - let requested_destination = tempfile::tempdir().expect("requested synthetic destination"); - let approvals = PartyStatementDestinationApprovals::default(); - let approval_id = approvals - .issue(approved_destination.path().to_path_buf()) - .expect("approve first destination"); - - let error = export_bulk_party_statements_at_selected_destination( - bulk_export_request(requested_destination.path(), &approval_id), - &approvals, - ) - .expect_err("an approval cannot be substituted for another destination"); - - assert!(matches!( - error, - BulkPartyStatementExportError::DestinationNotAuthorized(error) - if error.code == "statement_destination_not_authorized" - )); - let reuse_error = export_bulk_party_statements_at_selected_destination( - bulk_export_request(approved_destination.path(), &approval_id), - &approvals, - ) - .expect_err("a mismatched approval must be consumed"); - assert!(matches!( - reuse_error, - BulkPartyStatementExportError::DestinationNotAuthorized(error) - if error.code == "statement_destination_not_authorized" - )); - assert!( - std::fs::read_dir(requested_destination.path()) - .expect("requested destination remains readable") - .next() - .is_none(), - "the rejected mismatch must not write a statement or manifest" - ); - assert!( - std::fs::read_dir(approved_destination.path()) - .expect("approved destination remains readable") - .next() - .is_none(), - "a consumed mismatch approval must not write after a retry" - ); - } - - #[test] - fn bulk_statement_export_keeps_the_existing_deleted_destination_failure() { - let destination = tempfile::tempdir().expect("selected synthetic destination"); - let destination_path = destination.path().to_path_buf(); - let approvals = PartyStatementDestinationApprovals::default(); - let approval_id = approvals - .issue(destination_path.clone()) - .expect("record picker destination"); - destination.close().expect("remove selected destination"); - - let error = export_bulk_party_statements_at_selected_destination( - bulk_export_request(&destination_path, &approval_id), - &approvals, - ) - .expect_err("a deleted selected destination must fail the existing directory check"); - - assert!(matches!( - error, - BulkPartyStatementExportError::Existing(message) - if message == "Bridge could not use that statement destination folder." - )); - } - - #[test] - fn statement_export_rejects_unknown_bill_direction_at_the_ipc_boundary() { - let request = serde_json::json!({ - "company": "Synthetic Books Pvt Ltd", - "as_of_yyyymmdd": "20260808", - "party": "Synthetic Party", - "open_bills": [{ - "party": "Synthetic Party", - "reference": "INV-1", - "bill_date": "20260801", - "due_date": "20260831", - "amount": "100.00", - "age_days": 7, - "kind": "unknown" - }], - "unallocated_by_party": [] - }); - - assert!(serde_json::from_value::(request).is_err()); - } - - #[test] - fn local_export_file_names_reject_path_like_and_hidden_values() { - assert_eq!( - checked_export_file_name(" statement.csv ").unwrap(), - "statement.csv" - ); - for name in [ - "", - ".hidden.csv", - "../statement.csv", - "nested/report.csv", - "nested\\report.csv", - ] { - assert!( - checked_export_file_name(name).is_err(), - "{name:?} must be rejected" - ); - } - } - - #[test] - fn statement_party_slug_is_portable_and_nonempty() { - assert_eq!(statement_filename_slug(" ../Aarav & Sons "), "aarav-sons"); - assert_eq!(statement_filename_slug("///"), "party"); - } -} +#[path = "commands_party_statement_export_tests.rs"] +mod party_statement_export_tests; #[derive(Debug, Deserialize)] pub struct BaseCurrencyRequest { diff --git a/src-tauri/src/commands_party_statement_export_tests.rs b/src-tauri/src/commands_party_statement_export_tests.rs new file mode 100644 index 00000000..9be6057e --- /dev/null +++ b/src-tauri/src/commands_party_statement_export_tests.rs @@ -0,0 +1,314 @@ +use super::*; + +#[test] +fn utf8_picker_destination_round_trips_to_the_authorized_path() { + let destination = tempfile::tempdir().expect("temporary destination"); + let selected_path = destination.path().to_path_buf(); + let approvals = PartyStatementDestinationApprovals::default(); + let approval_id = approvals + .issue(selected_path.clone()) + .expect("approve picker destination"); + let ipc_destination = + require_utf8_destination(selected_path).expect("temporary destination is valid UTF-8"); + + let approved = approvals + .consume(&approval_id, std::path::Path::new(&ipc_destination)) + .expect("IPC path reconstruction retains the selected destination"); + + assert_eq!(approved.path(), std::path::Path::new(&ipc_destination)); +} + +fn bulk_export_request( + destination: &std::path::Path, + approval_id: &str, +) -> ExportBulkPartyStatementsRequest { + ExportBulkPartyStatementsRequest { + company: "Synthetic Books Pvt Ltd".to_string(), + as_of_yyyymmdd: "20260808".to_string(), + format: PartyStatementFormat::Xlsx, + ageing_anchor: crate::tally::OutstandingsAgeingAnchor::DueDate, + destination: destination.to_string_lossy().into_owned(), + approval_id: approval_id.to_string(), + open_bills: vec![OpenBillRow { + party: "Synthetic Party".to_string(), + reference: "SYNTHETIC-1".to_string(), + bill_date: "20260801".to_string(), + due_date: "20260831".to_string(), + amount: bridge_tally_core::ExactDecimal::parse("100.00").expect("synthetic amount"), + age_days: Some(7), + kind: crate::tally::ExposureDirection::Receivable, + }], + unallocated_by_party: Vec::new(), + } +} + +#[test] +fn statement_export_format_defaults_to_xlsx_and_accepts_pdf() { + let base = serde_json::json!({ + "company": "Synthetic Books Pvt Ltd", + "as_of_yyyymmdd": "20260808", + "party": "Synthetic Party", + "open_bills": [], + "unallocated_by_party": [], + }); + let defaulted: ExportPartyStatementRequest = serde_json::from_value(base.clone()).unwrap(); + assert!(matches!(defaulted.format, PartyStatementFormat::Xlsx)); + assert!(matches!( + defaulted.ageing_anchor, + crate::tally::OutstandingsAgeingAnchor::DueDate + )); + + let mut pdf = base; + pdf["format"] = serde_json::Value::String("pdf".to_string()); + let pdf: ExportPartyStatementRequest = serde_json::from_value(pdf).unwrap(); + assert!(matches!(pdf.format, PartyStatementFormat::Pdf)); +} + +#[test] +fn bulk_statement_export_defaults_the_ageing_anchor_for_legacy_callers() { + let request: ExportBulkPartyStatementsRequest = serde_json::from_value(serde_json::json!({ + "company": "Synthetic Books Pvt Ltd", + "as_of_yyyymmdd": "20260808", + "format": "xlsx", + "destination": "/tmp/statements", + "approval_id": "synthetic-approval", + "open_bills": [], + "unallocated_by_party": [], + })) + .unwrap(); + + assert!(matches!( + request.ageing_anchor, + crate::tally::OutstandingsAgeingAnchor::DueDate + )); +} + +#[test] +fn bulk_statement_export_rejects_an_unselected_destination_without_writing() { + let destination = tempfile::tempdir().expect("unselected synthetic destination"); + let approvals = PartyStatementDestinationApprovals::default(); + + let error = export_bulk_party_statements_at_selected_destination( + bulk_export_request(destination.path(), "not-issued"), + &approvals, + ) + .expect_err("an unselected destination must be rejected"); + + match error { + BulkPartyStatementExportError::DestinationNotAuthorized(error) => { + assert_eq!(error.code, "statement_destination_not_authorized"); + assert_eq!(error.retry, "after_change"); + assert!(!error.local_state_changed); + } + BulkPartyStatementExportError::Existing(error) => { + panic!("expected typed destination error, got {error}"); + } + } + assert!( + std::fs::read_dir(destination.path()) + .expect("destination remains readable") + .next() + .is_none(), + "the rejected batch must not write a statement or manifest" + ); +} + +#[test] +fn bulk_statement_export_writes_to_the_destination_approved_by_the_picker() { + let destination = tempfile::tempdir().expect("selected synthetic destination"); + let approvals = PartyStatementDestinationApprovals::default(); + let approval_id = approvals + .issue(destination.path().to_path_buf()) + .expect("record picker destination"); + + let result = export_bulk_party_statements_at_selected_destination( + bulk_export_request(destination.path(), &approval_id), + &approvals, + ) + .expect("recorded destination is accepted"); + + assert_eq!(result.written.len(), 1); + assert!(std::path::Path::new(&result.manifest_path).is_file()); + assert!(destination + .path() + .join(&result.written[0].file_name) + .is_file()); +} + +#[test] +fn independent_picker_approvals_export_to_their_own_destinations() { + let first_destination = tempfile::tempdir().expect("first synthetic destination"); + let second_destination = tempfile::tempdir().expect("second synthetic destination"); + let approvals = PartyStatementDestinationApprovals::default(); + // Both selections exist before either export starts; neither replaces + // the other as the previous singleton store did. + let first_approval = approvals + .issue(first_destination.path().to_path_buf()) + .expect("approve first destination"); + let second_approval = approvals + .issue(second_destination.path().to_path_buf()) + .expect("approve second destination"); + + let first = export_bulk_party_statements_at_selected_destination( + bulk_export_request(first_destination.path(), &first_approval), + &approvals, + ) + .expect("first approved destination writes"); + let second = export_bulk_party_statements_at_selected_destination( + bulk_export_request(second_destination.path(), &second_approval), + &approvals, + ) + .expect("second approved destination writes"); + + assert!(std::path::Path::new(&first.manifest_path).starts_with(first_destination.path())); + assert!(std::path::Path::new(&second.manifest_path).starts_with(second_destination.path())); + assert!(first_destination + .path() + .join(&first.written[0].file_name) + .is_file()); + assert!(second_destination + .path() + .join(&second.written[0].file_name) + .is_file()); +} + +#[test] +fn cancelled_picker_leaves_no_approval_that_can_export() { + let destination = tempfile::tempdir().expect("cancelled synthetic destination"); + let approvals = PartyStatementDestinationApprovals::default(); + + let error = export_bulk_party_statements_at_selected_destination( + bulk_export_request(destination.path(), "no-picker-selection"), + &approvals, + ) + .expect_err("a cancelled picker has no approval to consume"); + + assert!(matches!( + error, + BulkPartyStatementExportError::DestinationNotAuthorized(error) + if error.code == "statement_destination_not_authorized" + )); + assert!( + std::fs::read_dir(destination.path()) + .expect("destination remains readable") + .next() + .is_none(), + "the cancelled selection must not write a statement or manifest" + ); +} + +#[test] +fn approval_destination_mismatch_is_rejected_without_writing() { + let approved_destination = tempfile::tempdir().expect("approved synthetic destination"); + let requested_destination = tempfile::tempdir().expect("requested synthetic destination"); + let approvals = PartyStatementDestinationApprovals::default(); + let approval_id = approvals + .issue(approved_destination.path().to_path_buf()) + .expect("approve first destination"); + + let error = export_bulk_party_statements_at_selected_destination( + bulk_export_request(requested_destination.path(), &approval_id), + &approvals, + ) + .expect_err("an approval cannot be substituted for another destination"); + + assert!(matches!( + error, + BulkPartyStatementExportError::DestinationNotAuthorized(error) + if error.code == "statement_destination_not_authorized" + )); + let reuse_error = export_bulk_party_statements_at_selected_destination( + bulk_export_request(approved_destination.path(), &approval_id), + &approvals, + ) + .expect_err("a mismatched approval must be consumed"); + assert!(matches!( + reuse_error, + BulkPartyStatementExportError::DestinationNotAuthorized(error) + if error.code == "statement_destination_not_authorized" + )); + assert!( + std::fs::read_dir(requested_destination.path()) + .expect("requested destination remains readable") + .next() + .is_none(), + "the rejected mismatch must not write a statement or manifest" + ); + assert!( + std::fs::read_dir(approved_destination.path()) + .expect("approved destination remains readable") + .next() + .is_none(), + "a consumed mismatch approval must not write after a retry" + ); +} + +#[test] +fn bulk_statement_export_keeps_the_existing_deleted_destination_failure() { + let destination = tempfile::tempdir().expect("selected synthetic destination"); + let destination_path = destination.path().to_path_buf(); + let approvals = PartyStatementDestinationApprovals::default(); + let approval_id = approvals + .issue(destination_path.clone()) + .expect("record picker destination"); + destination.close().expect("remove selected destination"); + + let error = export_bulk_party_statements_at_selected_destination( + bulk_export_request(&destination_path, &approval_id), + &approvals, + ) + .expect_err("a deleted selected destination must fail the existing directory check"); + + assert!(matches!( + error, + BulkPartyStatementExportError::Existing(message) + if message == "Bridge could not use that statement destination folder." + )); +} + +#[test] +fn statement_export_rejects_unknown_bill_direction_at_the_ipc_boundary() { + let request = serde_json::json!({ + "company": "Synthetic Books Pvt Ltd", + "as_of_yyyymmdd": "20260808", + "party": "Synthetic Party", + "open_bills": [{ + "party": "Synthetic Party", + "reference": "INV-1", + "bill_date": "20260801", + "due_date": "20260831", + "amount": "100.00", + "age_days": 7, + "kind": "unknown" + }], + "unallocated_by_party": [] + }); + + assert!(serde_json::from_value::(request).is_err()); +} + +#[test] +fn local_export_file_names_reject_path_like_and_hidden_values() { + assert_eq!( + checked_export_file_name(" statement.csv ").unwrap(), + "statement.csv" + ); + for name in [ + "", + ".hidden.csv", + "../statement.csv", + "nested/report.csv", + "nested\\report.csv", + ] { + assert!( + checked_export_file_name(name).is_err(), + "{name:?} must be rejected" + ); + } +} + +#[test] +fn statement_party_slug_is_portable_and_nonempty() { + assert_eq!(statement_filename_slug(" ../Aarav & Sons "), "aarav-sons"); + assert_eq!(statement_filename_slug("///"), "party"); +} diff --git a/src-tauri/src/commands_statement_export_tests.rs b/src-tauri/src/commands_statement_export_tests.rs new file mode 100644 index 00000000..c08071fe --- /dev/null +++ b/src-tauri/src/commands_statement_export_tests.rs @@ -0,0 +1,31 @@ +use super::*; + +#[test] +fn repeated_statement_exports_get_distinct_new_files() { + let destination = tempfile::tempdir().expect("synthetic destination"); + let first = write_unique_statement_file( + destination.path(), + "statement-party-20260809", + "xlsx", + b"one", + ) + .expect("first statement"); + let second = write_unique_statement_file( + destination.path(), + "statement-party-20260809", + "xlsx", + b"two", + ) + .expect("second statement"); + + assert_eq!( + first.file_name().and_then(|name| name.to_str()), + Some("statement-party-20260809.xlsx") + ); + assert_eq!( + second.file_name().and_then(|name| name.to_str()), + Some("statement-party-20260809-2.xlsx") + ); + assert_eq!(std::fs::read(first).expect("first bytes"), b"one"); + assert_eq!(std::fs::read(second).expect("second bytes"), b"two"); +} diff --git a/src-tauri/src/commands_tests.rs b/src-tauri/src/commands_tests.rs new file mode 100644 index 00000000..2fd1b80c --- /dev/null +++ b/src-tauri/src/commands_tests.rs @@ -0,0 +1,754 @@ +/// `tally_runtime_command_error` classifies by substring-matching the +/// *top-level* message, so any error wrapper added anywhere upstream can +/// silently rewrite how unrelated readers classify. This pins the one +/// property that makes `PairedNativeReportResponseFailure` safe to add: it +/// is transparent, so the code is identical with and without it. +/// +/// Without transparency a stage-naming message would contain "report", +/// whose "port" substring routes to `endpoint_configuration_invalid` -- +/// blaming the endpoint configuration for a dropped connection. +#[test] +fn a_paired_report_response_marker_does_not_change_the_command_code() { + use crate::tally::connection::PairedNativeReportResponseFailure; + use bridge_tally_transport::TallyTransportError; + + for variant in [ + TallyTransportError::ConnectionFailed, + TallyTransportError::RequestTimedOut, + TallyTransportError::ResponseTooLarge { + limit: 1, + declared_by_peer: true, + }, + ] { + let bare = super::tally_runtime_command_error(anyhow::Error::new(variant.clone())); + let tagged = super::tally_runtime_command_error(anyhow::Error::new( + PairedNativeReportResponseFailure::new(anyhow::Error::new(variant.clone())), + )); + assert_eq!( + tagged.code, bare.code, + "the marker must not change how {variant:?} classifies" + ); + assert_ne!( + tagged.code, "endpoint_configuration_invalid", + "a transport fault must not be blamed on the endpoint configuration" + ); + } +} + +use super::{ + company_sweep_currency_preflight_failure, company_sweep_result, establish_inr_currency, + first_calendar_day_canary_window, load_client_group_labels_for_migration, + party_ledger_master_currency_admission_error, party_ledger_master_runtime_command_error, + portable_export_file_name, prepare_client_group_label_migration_from_labels, + reconcile_review_cleanup, reviewed_probe_commitment_sha256, selected_read_observation, + tally_command_error, tally_runtime_command_error, verify_observed_company_tuple_from_companies, + write_unique_download, ClientGroupLabelMigrationPreparationError, CompanySweepFailure, + OutstandingsRequest, PersistedTallyCompany, SavedTallySetup, SelectedCompanyIdentity, + VerifiedCompanyIdentity, +}; +// Used only by the `#[cfg(unix)]` non-UTF-8 destination test — an invalid-byte +// path cannot be constructed portably. The import must carry the same gate as +// the test, or Windows fails on an unused import under `-D warnings`. +#[cfg(unix)] +use super::require_utf8_destination; +use crate::tally::{ + ConnectionStatus, OutstandingsCurrencyAssertion, OutstandingsLoadResult, + SelectedReadObservation, TallyCompany, TallyLedger, TallyProbeResult, TallyProduct, +}; +use bridge_tally_core::CapabilityProfile; +use bridge_tally_protocol::PartyLedgerMasterFieldObservation; +use std::collections::BTreeMap; + +#[test] +fn migration_loader_returns_a_typed_error_for_unreadable_labels() { + let directory = tempfile::tempdir().expect("temporary config directory"); + std::fs::create_dir(directory.path().join("client-group-labels-v1.json")) + .expect("unreadable label path"); + + assert!(matches!( + load_client_group_labels_for_migration(directory.path()), + Err(ClientGroupLabelMigrationPreparationError::LabelsUnavailable) + )); +} + +#[tokio::test] +async fn empty_label_migration_plan_never_opens_the_mirror() { + let mirror_opened = std::sync::atomic::AtomicBool::new(false); + + let plan = prepare_client_group_label_migration_from_labels(BTreeMap::new(), |_| async { + mirror_opened.store(true, std::sync::atomic::Ordering::SeqCst); + Err(ClientGroupLabelMigrationPreparationError::MirrorUnavailable) + }) + .await + .expect("empty labels require no mirror"); + + assert!(plan.entries.is_empty()); + assert!(!mirror_opened.load(std::sync::atomic::Ordering::SeqCst)); +} + +#[test] +fn same_guid_case_or_whitespace_sibling_is_not_a_safe_scope() { + let identity = VerifiedCompanyIdentity::test_fixture("Client Book", "same-guid"); + let sibling = TallyCompany { + name: " client book ".to_string(), + guid: Some("SAME-GUID".to_string()), + company_number: Some("2".to_string()), + books_from: Some("20270401".to_string()), + }; + + assert!(identity.is_presentation_equivalent_guid_sibling(&sibling)); + assert!(!identity.matches_observed_company(&sibling)); +} + +#[test] +fn setup_rejects_presentation_equivalent_guid_siblings() { + let selected = SelectedCompanyIdentity { + display_name: "Client Book".to_string(), + company_guid: "same-guid".to_string(), + company_number: "1".to_string(), + books_from_yyyymmdd: "20260401".to_string(), + }; + let error = verify_observed_company_tuple_from_companies( + &selected, + vec![ + TallyCompany { + name: "Client Book".to_string(), + guid: Some("same-guid".to_string()), + company_number: Some("1".to_string()), + books_from: Some("20260401".to_string()), + }, + TallyCompany { + name: " client book ".to_string(), + guid: Some("SAME-GUID".to_string()), + company_number: Some("2".to_string()), + books_from: Some("20270401".to_string()), + }, + ], + ) + .expect_err("presentation-equivalent company sibling must not be saved"); + + assert_eq!(error.code, "company_identity_display_scope_ambiguous"); +} + +#[test] +fn setup_rejects_identical_name_same_guid_different_book() { + let selected = SelectedCompanyIdentity { + display_name: "Client Book".to_string(), + company_guid: "same-guid".to_string(), + company_number: "1".to_string(), + books_from_yyyymmdd: "20260401".to_string(), + }; + let error = verify_observed_company_tuple_from_companies( + &selected, + vec![ + TallyCompany { + name: "Client Book".to_string(), + guid: Some("same-guid".to_string()), + company_number: Some("1".to_string()), + books_from: Some("20260401".to_string()), + }, + TallyCompany { + name: "Client Book".to_string(), + guid: Some("same-guid".to_string()), + company_number: Some("2".to_string()), + books_from: Some("20270401".to_string()), + }, + ], + ) + .expect_err("an identically named sibling must not be saved or enrolled"); + + assert_eq!(error.code, "company_identity_display_scope_ambiguous"); +} + +#[test] +fn setup_reports_duplicate_complete_tuple_separately() { + let selected = SelectedCompanyIdentity { + display_name: "Client Book".to_string(), + company_guid: "same-guid".to_string(), + company_number: "1".to_string(), + books_from_yyyymmdd: "20260401".to_string(), + }; + let duplicate = TallyCompany { + name: "Client Book".to_string(), + guid: Some("same-guid".to_string()), + company_number: Some("1".to_string()), + books_from: Some("20260401".to_string()), + }; + let error = + verify_observed_company_tuple_from_companies(&selected, vec![duplicate.clone(), duplicate]) + .expect_err("a duplicate complete tuple must not be saved or enrolled"); + + assert_eq!(error.code, "company_identity_ambiguous"); +} + +/// Regression for the destination-picker leak: `select_party_statement_ +/// destination` used to build the folder's IPC string with +/// `to_string_lossy()`, which replaces invalid UTF-8 byte sequences with +/// U+FFFD -- silently turning the folder the operator picked into a +/// *different* path that likely doesn't exist. This failed before the +/// fix because the old conversion never returned an `Err` at all: it +/// always produced a (possibly wrong) string. +#[cfg(unix)] +#[test] +fn require_utf8_destination_rejects_non_utf8_paths_instead_of_rewriting_them() { + use std::ffi::OsStr; + use std::os::unix::ffi::OsStrExt; + + // 0xFF is never valid UTF-8 on its own, so this cannot be constructed + // as a Rust string literal -- it has to come in through the raw byte + // API a real OS path could actually hand back. + let invalid_bytes = [b'p', b'i', b'c', b'k', 0xFF, b'e', b'd']; + let path = std::path::PathBuf::from(OsStr::from_bytes(&invalid_bytes)); + + let error = + require_utf8_destination(path).expect_err("non-UTF-8 folder names must be rejected"); + + assert!( + !error.contains('\u{FFFD}'), + "must not silently substitute a replacement character: {error}" + ); + assert!(error.to_lowercase().contains("unicode")); +} + +#[test] +fn outstandings_accepts_only_an_explicit_inr_currency_assertion() { + let accepted: OutstandingsRequest = serde_json::from_value(serde_json::json!({ + "config": { "host": "127.0.0.1", "port": 9000 }, + "selected_company": { + "display_name": "Synthetic Company", + "company_guid": "synthetic-guid", + "company_number": "100001", + "books_from_yyyymmdd": "20260401" + }, + "currency_assertion": "INR" + })) + .expect("INR is the one supported explicit assertion"); + assert_eq!( + accepted.currency_assertion, + OutstandingsCurrencyAssertion::Inr + ); + + let rejected = serde_json::from_value::(serde_json::json!({ + "config": { "host": "127.0.0.1", "port": 9000 }, + "selected_company": { + "display_name": "Synthetic Company", + "company_guid": "synthetic-guid", + "company_number": "100001", + "books_from_yyyymmdd": "20260401" + }, + "currency_assertion": "USD" + })); + assert!( + rejected.is_err(), + "unsupported currencies must not start a scan" + ); +} + +#[test] +fn company_sweep_keeps_probe_and_read_failures_in_band() { + let outcomes = [ + Ok(OutstandingsLoadResult::Partial { + reason: crate::tally::OutstandingsPartialReason::code("first_book_partial"), + synced_at_unix_ms: 1, + }), + Err(CompanySweepFailure::ReasonCode( + "company_currency_probe_failed", + )), + Err(CompanySweepFailure::ReasonCode( + "company_base_currency_undetermined", + )), + Err(CompanySweepFailure::ReasonCode( + "company_outstandings_read_failed", + )), + Ok(OutstandingsLoadResult::Partial { + reason: crate::tally::OutstandingsPartialReason::code("last_book_partial"), + synced_at_unix_ms: 2, + }), + ] + .into_iter() + .map(company_sweep_result) + .collect::>(); + + assert_eq!( + outcomes.len(), + 5, + "one bad book must not truncate the sweep" + ); + assert!(matches!( + &outcomes[1], + OutstandingsLoadResult::Partial { reason, .. } + if reason.reason_code == "company_currency_probe_failed" + )); + assert!(matches!( + &outcomes[2], + OutstandingsLoadResult::Partial { reason, .. } + if reason.reason_code == "company_base_currency_undetermined" + )); + assert!(matches!( + &outcomes[3], + OutstandingsLoadResult::Partial { reason, .. } + if reason.reason_code == "company_outstandings_read_failed" + )); + assert!(matches!( + &outcomes[4], + OutstandingsLoadResult::Partial { reason, .. } + if reason.reason_code == "last_book_partial" + )); +} + +#[test] +fn company_sweep_preserves_a_company_listing_transport_reason() { + let result = company_sweep_result(Err(CompanySweepFailure::CompanyVerification( + tally_command_error( + "endpoint_unreachable", + "Endpoint configuration", + "Synthetic transport failure", + "after_change", + false, + "Restore connectivity.", + ), + ))); + + assert!(matches!( + result, + OutstandingsLoadResult::Partial { reason, .. } + if reason.reason_code == "endpoint_unreachable" + )); +} + +#[test] +fn company_sweep_currency_preflight_names_undetermined_base_currency() { + assert_eq!( + company_sweep_currency_preflight_failure(2, false), + Some("company_base_currency_undetermined"), + "several currency masters do not identify the company's base currency" + ); + assert_eq!( + company_sweep_currency_preflight_failure(2, true), + Some("company_base_currency_undetermined"), + "the parser's INR flag is not authoritative when several currency masters exist" + ); + assert_eq!( + company_sweep_currency_preflight_failure(1, false), + Some("company_base_currency_not_inr"), + "one non-Indian currency identifies an unsupported base currency" + ); + assert_eq!(company_sweep_currency_preflight_failure(1, true), None); + assert_eq!( + establish_inr_currency(1, true), + Ok(OutstandingsCurrencyAssertion::Inr), + "the backend boundary receives a typed INR admission only after the probe established one INR master" + ); + assert_eq!( + company_sweep_currency_preflight_failure(0, false), + Some("company_currency_probe_failed"), + "an impossible empty collection remains fail-closed" + ); +} + +#[test] +fn company_sweep_preserves_runtime_foreign_currency_partial() { + let outcome = company_sweep_result(Ok(OutstandingsLoadResult::Partial { + reason: crate::tally::OutstandingsPartialReason::foreign_currency_ledger_balance( + "Synthetic FX Debtor".to_string(), + ), + synced_at_unix_ms: 1, + })); + + assert!(matches!( + outcome, + OutstandingsLoadResult::Partial { reason, .. } + if reason.reason_code == "company_foreign_currency_ledger_balance" + && reason.foreign_currency_ledger_name.as_deref() == Some("Synthetic FX Debtor") + )); +} + +#[test] +fn export_names_are_portable_and_reserved_devices_are_neutralized() { + assert_eq!( + portable_export_file_name("outstandings-A:B?C.csv").unwrap(), + "outstandings-A-B-C.csv" + ); + assert_eq!(portable_export_file_name("CON.csv").unwrap(), "_CON.csv"); + assert!(portable_export_file_name("../outside.csv").is_err()); + assert!(portable_export_file_name("nested/report.csv").is_err()); +} + +#[test] +fn report_downloads_never_overwrite_an_existing_file() { + let directory = tempfile::tempdir().expect("temporary download directory"); + let first = write_unique_download(directory.path(), "report.csv", b"first").unwrap(); + let second = write_unique_download(directory.path(), "report.csv", b"second").unwrap(); + + assert_eq!(first.file_name().unwrap(), "report.csv"); + assert_eq!(second.file_name().unwrap(), "report-2.csv"); + assert_eq!(std::fs::read(first).unwrap(), b"first"); + assert_eq!(std::fs::read(second).unwrap(), b"second"); +} + +#[test] +fn snapshot_capability_canary_is_exactly_the_requested_first_calendar_day() { + let canary = first_calendar_day_canary_window("20260228").unwrap(); + assert_eq!(canary.range.from_yyyymmdd, "20260228"); + assert_eq!(canary.range.to_yyyymmdd, "20260228"); + assert_eq!(canary.query_profile.as_str(), "core_accounting_v3"); + + let same = first_calendar_day_canary_window("20260228").unwrap(); + assert_eq!(same, canary); + assert!(first_calendar_day_canary_window("20260229").is_err()); + assert!(first_calendar_day_canary_window("2026-02-28").is_err()); +} + +#[test] +fn tally_runtime_error_serialization_is_stable_and_redacted() { + let error = tally_runtime_command_error(anyhow::anyhow!( + "synthetic reqwest failure at http://127.0.0.1:9000/?token=private" + )); + let json = serde_json::to_string(&error).expect("serialize safe Tally command error"); + assert_eq!(error.code, "endpoint_unreachable"); + assert_eq!(error.category, "Endpoint configuration"); + assert!(error.local_state_changed); + assert!(!error.tally_state_may_have_changed); + assert!(!json.contains("token=private")); + assert!(!json.contains("reqwest")); + + let invalid_config = + tally_runtime_command_error(anyhow::anyhow!("Tally port must be between 1 and 65535")); + assert_eq!(invalid_config.code, "endpoint_configuration_invalid"); + assert!(!invalid_config.local_state_changed); + + let queue_deadline = + tally_runtime_command_error(anyhow::anyhow!("endpoint queue deadline exceeded")); + assert_eq!(queue_deadline.code, "tally_runtime_temporarily_unavailable"); + assert!(queue_deadline.local_state_changed); + + let deadline = tally_runtime_command_error( + anyhow::Error::new(bridge_tally_transport::TallyTransportError::RequestTimedOut) + .context("outstandings second segment read failed for 20251002..20251101"), + ); + assert_eq!(deadline.code, "tally_request_deadline_exceeded"); + assert_eq!(deadline.retry, "after_change"); + assert!(deadline.remediation.contains("Do not retry")); + + let discovery_limit = tally_runtime_command_error(anyhow::anyhow!( + "interactive discovery listing limit exceeded: synthetic company response" + )); + assert_eq!(discovery_limit.code, "untrusted_discovery_limit_exceeded"); + assert_eq!(discovery_limit.category, "Discovery listing"); +} + +#[test] +fn opening_balance_source_disagreement_is_response_validation_not_endpoint_failure() { + let error = tally_runtime_command_error(anyhow::Error::new( + crate::tally::connection::PartyLedgerMasterSourceValidationError::OpeningBalancesDisagreed, + )); + + assert_eq!(error.code, "response_validation_failed"); + assert_eq!(error.category, "Response validation"); + assert_ne!(error.code, "endpoint_unreachable"); +} + +#[test] +fn every_party_master_source_validation_is_not_an_endpoint_failure() { + use crate::tally::connection::PartyLedgerMasterSourceValidationError as Validation; + + for error in [ + Validation::MasterPeriod, + Validation::BalancePeriod, + Validation::MasterGuid, + Validation::MasterId, + Validation::MasterAlterId, + Validation::MasterOpeningBalance, + Validation::DuplicateMasterIdentity, + Validation::BalanceMissingMasterLedger, + Validation::OpeningBalancesDisagreed, + Validation::BalanceLedgerAbsentFromMasterEvidence, + Validation::DuplicateBalanceDisplayKey, + Validation::BalanceCompanyIdentityUnverified, + Validation::GroupCompanyIdentityUnverified, + Validation::MasterResponseInvalid { + source: anyhow::anyhow!("synthetic parser failure"), + }, + ] { + let mapped = tally_runtime_command_error(anyhow::Error::new(error)); + assert_eq!(mapped.code, "response_validation_failed"); + assert_eq!(mapped.category, "Response validation"); + assert_ne!(mapped.code, "endpoint_unreachable"); + } +} + +#[test] +fn retained_wire_evidence_does_not_hide_typed_command_refusals() { + use crate::tally::runtime::{with_read_evidence, RuntimeReadEvidence}; + for (source, code) in [ + (anyhow::Error::new(crate::tally::runtime::OpeningBoundaryObservationError::Unobserved), "response_validation_failed"), + (anyhow::Error::new(crate::tally::runtime::OpeningBoundaryObservationError::Changed), "response_validation_failed"), + (anyhow::Error::new(crate::tally::connection::PartyLedgerMasterSourceValidationError::OpeningBalancesDisagreed), "response_validation_failed"), + (anyhow::Error::new(crate::tally::connection::PairedReadValidationError::PartyLedgerMaster), "response_validation_failed"), + (anyhow::Error::new(crate::tally::runtime::TallyRuntimeControlError::QueueDeadline), "tally_runtime_temporarily_unavailable"), + ] { + let wrapped = with_read_evidence(source, RuntimeReadEvidence::empty()); + let mapped = tally_runtime_command_error(wrapped); + assert_eq!(mapped.code, code); + assert!(!mapped.message.contains("opening balances disagreed")); + } +} + +#[test] +fn drifted_party_master_read_is_response_validation_not_endpoint_failure() { + let error = tally_runtime_command_error(anyhow::Error::new( + crate::tally::connection::PairedReadValidationError::PartyLedgerMaster, + )); + + assert_eq!(error.code, "response_validation_failed"); + assert_eq!(error.category, "Response validation"); + assert_ne!(error.code, "endpoint_unreachable"); +} + +#[test] +fn party_master_export_keeps_runtime_control_error_code_and_hides_runtime_detail() { + let error = party_ledger_master_runtime_command_error(anyhow::Error::new( + crate::tally::runtime::TallyRuntimeControlError::QueueDeadline, + )); + + assert_eq!(error.code, "tally_runtime_temporarily_unavailable"); + assert_eq!(error.retry, "safe"); + assert!(error.local_state_changed); + assert!(error + .message + .starts_with("Bridge withheld the party/ledger master:")); + assert!(!error.message.contains("QueueDeadline")); +} + +#[test] +fn party_master_currency_admission_does_not_misdiagnose_multiple_masters() { + let error = party_ledger_master_currency_admission_error("company_base_currency_undetermined"); + + assert_eq!(error.code, "company_base_currency_undetermined"); + assert!(error.message.contains("multiple Currency masters")); + assert!(error + .message + .contains("could not establish the selected company's base currency from this read")); + assert!(!error.message.contains("more than one base currency")); + assert!(error + .remediation + .contains("no operator confirmation can make this read safe")); + assert!(error + .remediation + .contains("Do not retry the unchanged export")); +} + +#[test] +fn party_master_currency_admission_does_not_direct_an_inr_retry_for_non_inr_company() { + let error = party_ledger_master_currency_admission_error("company_base_currency_not_inr"); + + assert_eq!(error.code, "company_base_currency_not_inr"); + assert!(error.remediation.contains("select a company")); + assert!(error + .remediation + .contains("A confirmation cannot change an unsupported base currency")); +} + +#[test] +fn shared_ledger_command_result_keeps_legacy_nullable_observation_json() { + let result = vec![ + TallyLedger { + name: "Returned ledger".to_string(), + parent: PartyLedgerMasterFieldObservation::Returned("Sundry Debtors".to_string()), + party_gstin: PartyLedgerMasterFieldObservation::Returned("29ABCDE1234F1Z5".to_string()), + opening_balance: Some("0".to_string()), + }, + TallyLedger { + name: "Empty ledger".to_string(), + parent: PartyLedgerMasterFieldObservation::Returned(String::new()), + party_gstin: PartyLedgerMasterFieldObservation::Returned(String::new()), + opening_balance: None, + }, + TallyLedger { + name: "Unobserved ledger".to_string(), + parent: PartyLedgerMasterFieldObservation::NotObserved, + party_gstin: PartyLedgerMasterFieldObservation::NotObserved, + opening_balance: None, + }, + ]; + + assert_eq!( + serde_json::to_value(result).expect("command result serializes"), + serde_json::json!([ + { + "name": "Returned ledger", + "parent": "Sundry Debtors", + "party_gstin": "29ABCDE1234F1Z5", + "opening_balance": "0", + }, + { + "name": "Empty ledger", + "parent": "", + "party_gstin": "", + "opening_balance": null, + }, + { + "name": "Unobserved ledger", + "parent": null, + "party_gstin": null, + "opening_balance": null, + }, + ]) + ); +} + +#[test] +fn explicit_tally_error_preserves_atomic_failure_truth() { + let error = tally_command_error( + "reviewed_setup_store_failed", + "Operation", + "Synthetic reviewed setup was not stored", + "after_change", + false, + "Inspect local encrypted storage.", + ); + assert_eq!(error.code, "reviewed_setup_store_failed"); + assert!(!error.local_state_changed); + assert!(!error.tally_state_may_have_changed); +} + +#[test] +fn post_commit_review_cleanup_failure_preserves_durable_success_truth() { + let saved = SavedTallySetup { + passport_snapshot_id: "snapshot-1".to_string(), + canonical_origin: "http://127.0.0.1:9000".to_string(), + observed_at_unix_ms: 1_000, + company: PersistedTallyCompany { + name: "Synthetic Company".to_string(), + guid: Some("synthetic-guid".to_string()), + company_number: Some("100001".to_string()), + books_from_yyyymmdd: Some("20260401".to_string()), + mirror_company_id: Some("company-1".to_string()), + correlation_key: Some("c".repeat(64)), + identity_confidence: "observed", + }, + review_cleanup_warning: None, + }; + let result = reconcile_review_cleanup(Ok(saved), false).expect("save stays successful"); + assert_eq!( + result.review_cleanup_warning, + Some("review_cache_cleanup_failed_after_save") + ); + + let failed = reconcile_review_cleanup( + Err(tally_command_error( + "reviewed_setup_store_failed", + "Operation", + "Synthetic failure", + "after_change", + false, + "Retry.", + )), + false, + ) + .expect_err("failed store plus failed cleanup is explicit"); + assert_eq!(failed.code, "reviewed_setup_retry_state_uncertain"); + assert!(failed.local_state_changed); +} + +#[test] +fn reviewed_probe_commitment_binds_time_company_name_and_full_company_list() { + let probe = |names: &[&str]| TallyProbeResult { + connection: ConnectionStatus { + reachable: true, + compatible: false, + server_text: "Synthetic status".to_string(), + product: TallyProduct::Unknown, + error: None, + }, + companies: names + .iter() + .enumerate() + .map(|(index, name)| TallyCompany { + name: (*name).to_string(), + guid: Some(format!("guid-{index}")), + company_number: Some(format!("1000{index}")), + books_from: Some("20260401".to_string()), + }) + .collect(), + profile: CapabilityProfile { + profile_version: 2, + product: "Unknown".to_string(), + release: None, + license_tier: None, + mode: None, + transports: BTreeMap::new(), + features: BTreeMap::new(), + packs: BTreeMap::new(), + }, + selected_read_scope: None, + passport_snapshot_id: None, + }; + let first = reviewed_probe_commitment_sha256( + "review-a", + "http://127.0.0.1:9000", + 1_000, + &probe(&["Synthetic A"]), + ) + .unwrap(); + let renamed = reviewed_probe_commitment_sha256( + "review-a", + "http://127.0.0.1:9000", + 1_000, + &probe(&["Synthetic Renamed"]), + ) + .unwrap(); + let expanded = reviewed_probe_commitment_sha256( + "review-a", + "http://127.0.0.1:9000", + 1_000, + &probe(&["Synthetic A", "Synthetic B"]), + ) + .unwrap(); + let later = reviewed_probe_commitment_sha256( + "review-a", + "http://127.0.0.1:9000", + 1_001, + &probe(&["Synthetic A"]), + ) + .unwrap(); + assert_ne!(first, renamed); + assert_ne!(first, expanded); + assert_ne!(first, later); + let different_review = reviewed_probe_commitment_sha256( + "review-b", + "http://127.0.0.1:9000", + 1_000, + &probe(&["Synthetic A"]), + ) + .unwrap(); + assert_ne!(first, different_review); +} + +#[test] +fn selected_read_observation_distinguishes_empty_identity_evidence() { + let observation = |bucket| SelectedReadObservation { + request_sha256: "a".repeat(64), + decoded_response_sha256: "b".repeat(64), + response_encoding: "utf8", + result_bucket: bucket, + }; + let empty = selected_read_observation( + "selected_ledger_read", + Ok(observation("empty_observed")), + false, + "selected_ledger_read_empty_observed", + "selected_ledger_read_non_empty_observed", + ); + assert_eq!(empty.identity_evidence_state, "not_applicable_empty"); + assert!(empty.record_count_verified); + + let populated = selected_read_observation( + "selected_ledger_read", + Ok(observation("non_empty_observed")), + false, + "selected_ledger_read_empty_observed", + "selected_ledger_read_non_empty_observed", + ); + assert_eq!(populated.identity_evidence_state, "verified"); +} diff --git a/src-tauri/src/sync/reconciliation.rs b/src-tauri/src/sync/reconciliation.rs index 6da24f3b..b8cd3c38 100644 --- a/src-tauri/src/sync/reconciliation.rs +++ b/src-tauri/src/sync/reconciliation.rs @@ -1704,875 +1704,5 @@ fn pack_object_types(pack: CapabilityPackId) -> &'static [&'static str] { } #[cfg(test)] -mod tests { - use bridge_tally_core::{ - source_count_scope_fingerprint, ExactDecimal, LedgerEntryPolarity, LedgerEntryRecord, - LedgerRecord, ObservedSourceIdentities, RawSourceSha256, SourceAlterId, SourceRecordId, - SourceReportedCountEvidence, VoucherRecord, VoucherTypeRecord, - }; - - use super::*; - use serde_json::json; - - fn source_identity() -> SourceIdentity { - SourceIdentity { - bridge_source_lineage: "lineage-1".to_string(), - company_guid: "company-guid".to_string(), - observed_fingerprint: "fingerprint".to_string(), - } - } - - fn balanced_batch(reverse: bool) -> PackBatch { - let mut batch = CoreAccountingBatch { - ledgers: vec![ - LedgerRecord { - source_id: "ledger-b".to_string(), - name: "B".to_string(), - parent_source_id: None, - opening_balance: None, - }, - LedgerRecord { - source_id: "ledger-a".to_string(), - name: "A".to_string(), - parent_source_id: None, - opening_balance: None, - }, - ], - voucher_types: vec![VoucherTypeRecord { - source_id: "sales".to_string(), - name: "Sales".to_string(), - }], - vouchers: vec![VoucherRecord { - source_id: "voucher-1".to_string(), - date_yyyymmdd: "20260701".to_string(), - voucher_type_source_id: "sales".to_string(), - voucher_number: Some("1".to_string()), - cancelled: false, - optional: false, - }], - ledger_entries: vec![ - LedgerEntryRecord { - source_id: "entry-credit".to_string(), - voucher_source_id: "voucher-1".to_string(), - ledger_source_id: "ledger-b".to_string(), - amount: ExactDecimal::parse("-100.00").unwrap(), - polarity: LedgerEntryPolarity::Debit, - }, - LedgerEntryRecord { - source_id: "entry-debit".to_string(), - voucher_source_id: "voucher-1".to_string(), - ledger_source_id: "ledger-a".to_string(), - amount: ExactDecimal::parse("100").unwrap(), - polarity: LedgerEntryPolarity::Credit, - }, - ], - ..CoreAccountingBatch::default() - }; - if reverse { - batch.ledgers.reverse(); - batch.ledger_entries.reverse(); - } - PackBatch::CoreAccounting(batch) - } - - fn window() -> ReadWindow { - ReadWindow { - from_yyyymmdd: "20260701".to_string(), - to_yyyymmdd: "20260731".to_string(), - } - } - - fn query_profile() -> CanonicalText { - CanonicalText::parse("core-accounting-v1").unwrap() - } - - fn filters_sha256() -> CanonicalText { - CanonicalText::parse("a".repeat(64)).unwrap() - } - - fn canonicalize_test( - batch: PackBatch, - source_counts: Option>, - ) -> CanonicalWindow { - canonicalize_window( - &CanonicalWindowContext { - requested_pack: CapabilityPackId::CoreAccounting, - schema_version: PackSchemaVersion { major: 1, minor: 0 }, - source_identity: &source_identity(), - query_profile: &query_profile(), - filters_sha256: &filters_sha256(), - external_references: &ExternalReferenceCatalog::Unavailable, - window_id: "window-1", - requested_window: &window(), - }, - &CanonicalPackWindow { - batch, - source_counts, - record_evidence: None, - }, - ) - .unwrap() - } - - fn complete_core_counts() -> Vec { - [ - ("group", 0), - ("ledger", 2), - ("voucher_type", 1), - ("voucher", 1), - ("ledger_entry", 2), - ] - .into_iter() - .map(|(object_type, source_reported_count)| { - let object_type = CanonicalText::parse(object_type).unwrap(); - let descriptor = SourceCountScopeDescriptor { - source_identity: source_identity(), - pack: CapabilityPackId::CoreAccounting, - pack_schema_version: PackSchemaVersion { major: 1, minor: 0 }, - object_type: object_type.clone(), - query_profile: query_profile(), - filters_sha256: filters_sha256(), - window: None, - }; - SourceReportedCountEvidence { - object_type, - query_profile: query_profile(), - source_scope_fingerprint: source_count_scope_fingerprint( - &descriptor, - SourceCountScope::Complete, - ) - .unwrap(), - source_count_scope: SourceCountScope::Complete, - source_reported_count, - } - }) - .collect() - } - - fn complete_core_record_evidence() -> Vec { - [ - ("ledger", "ledger-a", SourceIdentityKind::Guid, '1'), - ("ledger", "ledger-b", SourceIdentityKind::RemoteId, '2'), - ("voucher_type", "sales", SourceIdentityKind::Fallback, '3'), - ("voucher", "voucher-1", SourceIdentityKind::MasterId, '4'), - ( - "ledger_entry", - "entry-credit", - SourceIdentityKind::RemoteId, - '5', - ), - ("ledger_entry", "entry-debit", SourceIdentityKind::Guid, '6'), - ] - .into_iter() - .map(|(object_type, source_id, identity_kind, hash_char)| { - let source_id = SourceRecordId::parse(source_id).unwrap(); - let mut observed_identities = ObservedSourceIdentities::default(); - match identity_kind { - SourceIdentityKind::Guid => observed_identities.guid = Some(source_id.clone()), - SourceIdentityKind::RemoteId => { - observed_identities.remote_id = Some(source_id.clone()) - } - SourceIdentityKind::MasterId => { - observed_identities.master_id = Some(source_id.clone()) - } - SourceIdentityKind::Fallback => {} - } - SourceRecordEvidence { - object_type: CanonicalText::parse(object_type).unwrap(), - source_id: source_id.clone(), - identity_kind, - observed_identities, - raw_source_sha256: RawSourceSha256::parse(hash_char.to_string().repeat(64)) - .unwrap(), - alter_id: (source_id.as_str() == "voucher-1") - .then(|| SourceAlterId::parse("alter:77").unwrap()), - } - }) - .collect() - } - - fn canonicalize_typed( - pack: CapabilityPackId, - batch: PackBatch, - external_references: ExternalReferenceCatalog, - ) -> CanonicalWindow { - canonicalize_window( - &CanonicalWindowContext { - requested_pack: pack, - schema_version: PackSchemaVersion { major: 1, minor: 0 }, - source_identity: &source_identity(), - query_profile: &CanonicalText::parse("typed-pack-v1").unwrap(), - filters_sha256: &filters_sha256(), - external_references: &external_references, - window_id: "window-1", - requested_window: &window(), - }, - &CanonicalPackWindow::without_source_count_evidence(batch), - ) - .unwrap() - } - - fn input(evidence: WindowEvidence) -> ReconciliationInput { - ReconciliationInput { - batch_id: "batch-1".to_string(), - run_id: "run-1".to_string(), - source_identity: source_identity(), - pack: CapabilityPackId::CoreAccounting, - pack_schema_version: PackSchemaVersion { major: 1, minor: 0 }, - started_at_unix_ms: 1_000, - completed_at_unix_ms: 2_000, - freshness_before: Freshness::NeverVerified, - freshness_target_seconds: 300, - planned_window_ids: BTreeSet::from(["window-1".to_string()]), - completed_windows: BTreeMap::from([("window-1".to_string(), evidence)]), - end_profile_check: EndProfileCheck::Unavailable, - source_stability_check: SourceStabilityCheck::Unavailable, - explicit_gap_codes: BTreeSet::new(), - warning_codes: BTreeSet::new(), - } - } - - #[test] - fn canonical_hash_is_stable_when_source_order_changes() { - let first = canonicalize_test(balanced_batch(false), None); - let second = canonicalize_test(balanced_batch(true), None); - assert_eq!( - first.evidence.canonical_sha256, - second.evidence.canonical_sha256 - ); - assert_eq!(first.observations.len(), 6); - assert!(first.evidence.mismatches.is_empty()); - } - - #[test] - fn mirror_input_preserves_identity_kind_raw_hash_and_alter_id() { - let source_window = CanonicalPackWindow { - batch: balanced_batch(false), - source_counts: None, - record_evidence: Some(complete_core_record_evidence()), - }; - let canonical = canonicalize_window( - &CanonicalWindowContext { - requested_pack: CapabilityPackId::CoreAccounting, - schema_version: PackSchemaVersion { major: 1, minor: 0 }, - source_identity: &source_identity(), - query_profile: &query_profile(), - filters_sha256: &filters_sha256(), - external_references: &ExternalReferenceCatalog::Unavailable, - window_id: "window-1", - requested_window: &window(), - }, - &source_window, - ) - .unwrap(); - assert_eq!( - canonical.evidence.record_provenance_scope, - ComparisonScope::Complete - ); - let voucher = canonical - .observations - .iter() - .find(|record| record.object_type == "voucher") - .unwrap(); - let mirror = voucher.mirror_input("batch-1", 1_000).unwrap(); - assert_eq!(mirror.identity.master_id.as_deref(), Some("voucher-1")); - assert!(mirror.identity.guid.is_none()); - assert!(mirror.identity.remote_id.is_none()); - assert!(mirror.identity.fallback_fingerprint.is_none()); - assert_eq!(mirror.raw_source_sha256, "4".repeat(64)); - assert_ne!(mirror.raw_source_sha256, voucher.canonical_sha256); - assert_eq!(mirror.observed_alter_id.as_deref(), Some("alter:77")); - } - - #[test] - fn missing_or_mismatched_record_provenance_never_reaches_staging_as_fabricated_data() { - let missing = canonicalize_test(balanced_batch(false), None); - assert_eq!( - missing.evidence.record_provenance_scope, - ComparisonScope::Unavailable - ); - assert!(matches!( - missing.observations[0].mirror_input("batch-1", 1_000), - Err(ReconciliationError::RecordProvenanceUnavailable) - )); - let decision = build_reconciliation(input(missing.evidence)).unwrap(); - assert!(decision - .mirror_commit - .parts() - .gap_codes - .contains(&"record_provenance_unavailable".to_string())); - - let mut evidence = complete_core_record_evidence(); - evidence[0].object_type = CanonicalText::parse("group").unwrap(); - let result = canonicalize_window( - &CanonicalWindowContext { - requested_pack: CapabilityPackId::CoreAccounting, - schema_version: PackSchemaVersion { major: 1, minor: 0 }, - source_identity: &source_identity(), - query_profile: &query_profile(), - filters_sha256: &filters_sha256(), - external_references: &ExternalReferenceCatalog::Unavailable, - window_id: "window-1", - requested_window: &window(), - }, - &CanonicalPackWindow { - batch: balanced_batch(false), - source_counts: None, - record_evidence: Some(evidence), - }, - ); - assert!(matches!( - result, - Err(ReconciliationError::RecordEvidenceMismatch) - )); - } - - #[test] - fn exact_decimal_reconciliation_detects_imbalance_without_float_math() { - let mut batch = balanced_batch(false); - let PackBatch::CoreAccounting(core) = &mut batch else { - unreachable!() - }; - core.ledger_entries[0].amount = ExactDecimal::parse("-99.999").unwrap(); - let canonical = canonicalize_test(batch, None); - assert!(canonical - .evidence - .mismatches - .iter() - .any(|mismatch| mismatch.safe_reason_code == "voucher_entries_unbalanced")); - let decision = build_reconciliation(input(canonical.evidence)).unwrap(); - assert_eq!(decision.proof.verification, CoreVerificationState::Partial); - assert!(decision.mirror_commit.parts().checkpoint_after.is_none()); - assert!(decision - .mirror_commit - .parts() - .gap_codes - .contains(&"reconciliation_mismatch".to_string())); - } - - #[test] - fn tally_polarity_is_independently_reconciled_from_signed_amounts() { - let mut batch = balanced_batch(false); - let PackBatch::CoreAccounting(core) = &mut batch else { - unreachable!() - }; - // Invert the WHOLE voucher, not one entry of it. Since bridge#392 a - // single disagreeing entry is contextual polarity, not a mismatch -- - // it is what an ordinary round-off leg looks like and it fired 111 - // times on one real book's financial year. A voucher every entry of - // which is inverted cannot be explained that way, and that is the - // shape this test needs: it is asserting that such a mismatch - // propagates to Partial verification, not that one flipped entry is - // individually detectable. - core.ledger_entries[0].polarity = LedgerEntryPolarity::Credit; - core.ledger_entries[1].polarity = LedgerEntryPolarity::Debit; - let canonical = canonicalize_test(batch, None); - assert!(canonical - .evidence - .mismatches - .iter() - .any(|mismatch| { mismatch.safe_reason_code == "voucher_entry_polarity_mismatch" })); - let decision = build_reconciliation(input(canonical.evidence)).unwrap(); - assert_eq!(decision.proof.verification, CoreVerificationState::Partial); - assert!(decision.mirror_commit.parts().checkpoint_after.is_none()); - assert!(decision - .mirror_commit - .parts() - .gap_codes - .contains(&"reconciliation_mismatch".to_string())); - } - - #[test] - fn cancelled_empty_voucher_is_not_a_false_missing_entry_failure() { - let mut batch = balanced_batch(false); - let PackBatch::CoreAccounting(core) = &mut batch else { - unreachable!() - }; - core.vouchers[0].cancelled = true; - core.ledger_entries.clear(); - let canonical = canonicalize_test(batch, None); - assert!(!canonical - .evidence - .accounting_gap_codes - .contains("voucher_entry_applicability_unavailable")); - assert!(!canonical - .evidence - .mismatches - .iter() - .any(|mismatch| mismatch.safe_reason_code == "voucher_entries_missing")); - } - - #[test] - fn unknown_non_cancelled_empty_voucher_applicability_is_a_proof_gap() { - let mut batch = balanced_batch(false); - let PackBatch::CoreAccounting(core) = &mut batch else { - unreachable!() - }; - core.ledger_entries.clear(); - let canonical = canonicalize_test(batch, None); - let decision = build_reconciliation(input(canonical.evidence)).unwrap(); - assert!(decision - .mirror_commit - .parts() - .gap_codes - .contains(&"voucher_entry_applicability_unavailable".to_string())); - assert_eq!(decision.proof.verification, CoreVerificationState::Partial); - assert!(decision.mirror_commit.parts().checkpoint_after.is_none()); - } - - #[test] - fn zero_amount_polarity_unavailability_prevents_checkpoint() { - let mut batch = balanced_batch(false); - let PackBatch::CoreAccounting(core) = &mut batch else { - unreachable!() - }; - core.ledger_entries[0].amount = ExactDecimal::parse("-0.00").unwrap(); - core.ledger_entries[1].amount = ExactDecimal::parse("0").unwrap(); - let canonical = canonicalize_test(batch, None); - let decision = build_reconciliation(input(canonical.evidence)).unwrap(); - assert!(decision - .mirror_commit - .parts() - .gap_codes - .contains(&"voucher_entry_polarity_unavailable".to_string())); - assert_eq!(decision.proof.verification, CoreVerificationState::Partial); - assert!(decision.mirror_commit.parts().checkpoint_after.is_none()); - } - - #[test] - fn missing_window_and_mismatch_can_never_create_a_checkpoint() { - let canonical = canonicalize_test(balanced_batch(false), None); - let mut input = input(canonical.evidence); - input.planned_window_ids.insert("window-2".to_string()); - let decision = build_reconciliation(input).unwrap(); - assert_eq!(decision.proof.verification, CoreVerificationState::Partial); - assert_eq!(decision.mirror_commit.parts().checkpoint_after, None); - assert!(decision - .mirror_commit - .parts() - .gap_codes - .contains(&"missing_snapshot_window".to_string())); - } - - #[test] - fn complete_counts_cannot_claim_verified_without_required_report_tie_out() { - let canonical = canonicalize_test(balanced_batch(false), Some(complete_core_counts())); - let first = build_reconciliation(input(canonical.evidence.clone())).unwrap(); - let second = build_reconciliation(input(canonical.evidence)).unwrap(); - assert_eq!(first.proof.verification, CoreVerificationState::Partial); - assert_eq!( - first.proof.snapshot_sha256, second.proof.snapshot_sha256, - "unchanged canonical state must hash identically" - ); - assert!(first.mirror_commit.parts().checkpoint_after.is_none()); - assert!(first - .mirror_commit - .parts() - .gap_codes - .contains(&"report_tie_out_unavailable".to_string())); - assert!(first - .mirror_commit - .parts() - .gap_codes - .contains(&"source_cut_consistency_unavailable".to_string())); - assert!(first - .mirror_commit - .parts() - .gap_codes - .contains(&"capability_profile_drift_check_unavailable".to_string())); - } - - #[test] - fn fresh_profile_and_full_reread_evidence_are_distinguished_from_atomicity() { - let canonical = canonicalize_test(balanced_batch(false), Some(complete_core_counts())); - let mut evidence = input(canonical.evidence); - evidence.end_profile_check = EndProfileCheck::Passed; - evidence.source_stability_check = SourceStabilityCheck::Passed; - let decision = build_reconciliation(evidence).unwrap(); - let gaps = &decision.mirror_commit.parts().gap_codes; - assert!(!gaps.contains(&"capability_profile_drift_check_unavailable".to_string())); - assert!(!gaps.contains(&"source_cut_consistency_unavailable".to_string())); - assert!(gaps.contains(&"source_cut_atomicity_unavailable".to_string())); - assert!(decision.mirror_commit.parts().checkpoint_after.is_none()); - } - - #[test] - fn end_profile_drift_is_a_proof_mismatch_not_cached_as_passed() { - let canonical = canonicalize_test(balanced_batch(false), Some(complete_core_counts())); - let mut evidence = input(canonical.evidence); - evidence.end_profile_check = EndProfileCheck::Mismatch; - let decision = build_reconciliation(evidence).unwrap(); - assert!(decision - .mirror_commit - .parts() - .gap_codes - .contains(&"capability_profile_changed_during_run".to_string())); - assert!(decision.mirror_commit.parts().checkpoint_after.is_none()); - } - - #[test] - fn missing_source_count_and_changed_identity_across_windows_fail_closed() { - let first = canonicalize_test(balanced_batch(false), None); - let unavailable = build_reconciliation(input(first.evidence.clone())).unwrap(); - assert_eq!( - unavailable.proof.verification, - CoreVerificationState::Partial - ); - assert!(unavailable - .mirror_commit - .parts() - .gap_codes - .contains(&"source_count_unavailable".to_string())); - assert!(unavailable.mirror_commit.parts().checkpoint_after.is_none()); - - let mut second_evidence = first.evidence.clone(); - second_evidence.window_id = "window-2".to_string(); - second_evidence.from_yyyymmdd = "20260801".to_string(); - second_evidence.to_yyyymmdd = "20260831".to_string(); - let identity = second_evidence - .canonical_records - .keys() - .next() - .expect("at least one record") - .clone(); - second_evidence - .canonical_records - .insert(identity, "f".repeat(64)); - let mut changed = input(first.evidence); - changed.planned_window_ids.insert("window-2".to_string()); - changed - .completed_windows - .insert("window-2".to_string(), second_evidence); - let decision = build_reconciliation(changed).unwrap(); - assert!(decision - .mirror_commit - .parts() - .gap_codes - .contains(&"source_changed_during_snapshot".to_string())); - assert!(decision.mirror_commit.parts().checkpoint_after.is_none()); - } - - #[test] - fn complete_count_once_covers_global_scope_without_per_window_repetition() { - let first = canonicalize_test(balanced_batch(false), Some(complete_core_counts())); - let second_window = ReadWindow { - from_yyyymmdd: "20260801".to_string(), - to_yyyymmdd: "20260831".to_string(), - }; - let second = canonicalize_window( - &CanonicalWindowContext { - requested_pack: CapabilityPackId::CoreAccounting, - schema_version: PackSchemaVersion { major: 1, minor: 0 }, - source_identity: &source_identity(), - query_profile: &query_profile(), - filters_sha256: &filters_sha256(), - external_references: &ExternalReferenceCatalog::Unavailable, - window_id: "window-2", - requested_window: &second_window, - }, - &CanonicalPackWindow::without_source_count_evidence(PackBatch::CoreAccounting( - CoreAccountingBatch::default(), - )), - ) - .unwrap(); - let mut reconciliation = input(first.evidence); - reconciliation - .planned_window_ids - .insert("window-2".to_string()); - reconciliation - .completed_windows - .insert("window-2".to_string(), second.evidence); - let decision = build_reconciliation(reconciliation).unwrap(); - assert_eq!(decision.proof.verification, CoreVerificationState::Partial); - assert!(decision - .mirror_commit - .parts() - .gap_codes - .contains(&"report_tie_out_unavailable".to_string())); - assert!(!decision - .mirror_commit - .parts() - .gap_codes - .iter() - .any(|code| code.starts_with("source_count_"))); - } - - #[test] - fn mismatch_drill_down_never_retains_raw_printable_source_ids() { - let raw_source_id = "CUSTOMER-LEDGER-PRINTABLE-123"; - let mismatch = safe_mismatch( - "synthetic_reference_missing", - vec![raw_source_id.to_string()], - ); - let encoded = serde_json::to_string(&mismatch).unwrap(); - assert!(!encoded.contains(raw_source_id)); - assert_eq!(mismatch.safe_record_ids.len(), 1); - assert!(mismatch.safe_record_ids[0].starts_with("rid:")); - assert_eq!(mismatch.safe_record_ids[0].len(), 68); - } - - #[test] - fn terminal_proofs_never_advance_a_checkpoint() { - for kind in [TerminalKind::Failed, TerminalKind::Cancelled] { - let decision = build_terminal_proof( - "batch-1".to_string(), - "run-1".to_string(), - source_identity(), - CapabilityPackId::CoreAccounting, - PackSchemaVersion { major: 1, minor: 0 }, - 1_000, - 2_000, - Freshness::Fresh, - 300, - kind, - "window_extract_failed".to_string(), - BTreeSet::from(["earlier_gap".to_string()]), - BTreeSet::from([WarningCode::AdaptiveWindowSplit]), - BTreeMap::from([ - ("locally_staged.accepted".to_string(), 2), - ("locally_staged.rejected".to_string(), 1), - ]), - ); - assert_eq!(decision.mirror_commit.parts().checkpoint_after, None); - assert_eq!( - decision.proof.verification, - CoreVerificationState::Unverified - ); - assert_eq!( - decision.mirror_commit.parts().gap_codes, - vec!["earlier_gap", "window_extract_failed"] - ); - assert_eq!( - decision.mirror_commit.parts().warning_codes, - vec!["adaptive_window_split"] - ); - assert_eq!( - decision - .proof - .gaps - .iter() - .map(|gap| gap.safe_reason_code.as_str()) - .collect::>(), - vec!["earlier_gap", "window_extract_failed"] - ); - assert_eq!(decision.proof.record_counts["locally_staged.accepted"], 2); - assert_eq!(decision.proof.record_counts["locally_staged.rejected"], 1); - assert_eq!( - decision.mirror_commit.parts().record_counts_sha256, - Some(proof_record_counts_sha256(&decision.proof.record_counts)) - ); - } - } - - #[test] - fn terminal_proof_clamps_backward_clock_and_records_the_gap() { - let decision = build_terminal_proof( - "batch-clock".to_string(), - "run-clock".to_string(), - source_identity(), - CapabilityPackId::CoreAccounting, - PackSchemaVersion { major: 1, minor: 0 }, - 2_000, - 1_999, - Freshness::NeverVerified, - 300, - TerminalKind::Failed, - "source_outcome_unknown".to_string(), - BTreeSet::new(), - BTreeSet::new(), - BTreeMap::new(), - ); - assert_eq!(decision.proof.completed_at_unix_ms, Some(2_000)); - assert_eq!(decision.mirror_commit.parts().completed_at_unix_ms, 2_000); - assert_eq!( - decision.mirror_commit.parts().gap_codes, - vec!["local_clock_moved_backwards", "source_outcome_unknown"] - ); - } - - #[test] - fn source_count_scope_fingerprint_mismatch_is_rejected_before_staging() { - let mut counts = complete_core_counts(); - counts[0].source_scope_fingerprint = CanonicalText::parse("b".repeat(64)).unwrap(); - let result = canonicalize_window( - &CanonicalWindowContext { - requested_pack: CapabilityPackId::CoreAccounting, - schema_version: PackSchemaVersion { major: 1, minor: 0 }, - source_identity: &source_identity(), - query_profile: &query_profile(), - filters_sha256: &filters_sha256(), - external_references: &ExternalReferenceCatalog::Unavailable, - window_id: "window-1", - requested_window: &window(), - }, - &CanonicalPackWindow { - batch: balanced_batch(false), - source_counts: Some(counts), - record_evidence: None, - }, - ); - assert!(matches!( - result, - Err(ReconciliationError::SourceCountScopeMismatch) - )); - } - - #[test] - fn typed_packs_preserve_exact_values_and_enforce_reference_integrity() { - let references = ExternalReferenceCatalog::Complete { - company_ids: BTreeSet::from(["company-guid".to_string()]), - voucher_ids: BTreeSet::from(["voucher:1".to_string(), "voucher:2".to_string()]), - ledger_ids: BTreeSet::from(["ledger:customer".to_string()]), - }; - let tax = bridge_tally_core::IndiaTaxBatch { - tax_registrations: vec![serde_json::from_value(json!({ - "source_id": "tax-registration:1", - "owner_kind": "ledger", - "owner_source_id": "ledger:customer", - "registration_type": "regular", - "gstin": "27ABCDE1234F1Z5" - })) - .unwrap()], - voucher_taxes: vec![serde_json::from_value(json!({ - "source_id": "voucher-tax:1", - "voucher_source_id": "voucher:1", - "place_of_supply": "27", - "assessable_value": "1000.00", - "tax_component": "igst", - "tax_rate": "18.00", - // Deliberately not assessable*rate/100: no rounding/formula profile exists. - "tax_amount": "179.99" - })) - .unwrap()], - }; - let tax_window = canonicalize_typed( - CapabilityPackId::IndiaTax, - PackBatch::IndiaTax(tax), - references.clone(), - ); - assert_eq!(tax_window.observations.len(), 2); - assert!(tax_window.evidence.mismatches.is_empty()); - assert_eq!( - tax_window.observations[1] - .exact_decimals - .get("tax_amount") - .map(String::as_str), - Some("179.99") - ); - - let bills: bridge_tally_core::BillsAndPaymentsBatch = serde_json::from_value(json!({ - "parties": [{ - "source_identity": { - "bridge_source_lineage": "bridge-source:test", - "company_guid": "company-guid:test", - "observed_fingerprint": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" - }, - "party_ledger_source_id": "ledger:customer", - "report_as_of_yyyymmdd": "20260731", - "direction": "receivable", - "bill_wise_state": "enabled_observed", - "allocation_coverage": "observed_complete_scope", - "outstanding_coverage": "observed_complete_scope", - "fetch_bracket": "stable_observed", - "query_profile": "bills-confidence-v1", - "source_scope_fingerprint": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "source_reported_allocation_count": 1, - "source_reported_outstanding_count": 1, - "allocations": [{ - "source_id": "bill:1", - "identity_basis": "parent_ordinal", - "origin": { - "origin": "voucher", - "voucher_source_id": "voucher:1", - "party_entry_source_id": "entry:party-1" - }, - "reference": { - "kind": "new_reference", - "name": "INV-1", - "raw_kind": null - }, - "bill_date_yyyymmdd": "20260701", - "effective_date_yyyymmdd": null, - "due_date_yyyymmdd": "20260731", - "due_date_evidence": "explicit", - "amount": "-1180.00", - "observed_polarity": "debit", - "currency_basis": { - "basis": "company_base", - "currency": "company-base" - } - }], - "outstanding": [{ - "source_id": "outstanding:1", - "identity_basis": "parent_ordinal", - "origin": { - "origin": "voucher", - "voucher_source_id": "voucher:1" - }, - "reference": { - "kind": "new_reference", - "name": "INV-1", - "raw_kind": null - }, - "bill_date_yyyymmdd": "20260701", - "effective_date_yyyymmdd": null, - "due_date_yyyymmdd": "20260731", - "due_date_evidence": "explicit", - "opening_amount": "-1180.00", - "pending_amount": "-1180.00", - "observed_polarity": "debit", - "source_reported_overdue_days": 0, - "currency_basis": { - "basis": "company_base", - "currency": "company-base" - } - }] - }] - })) - .unwrap(); - let bills_window = canonicalize_typed( - CapabilityPackId::BillsAndPayments, - PackBatch::BillsAndPayments(bills), - references.clone(), - ); - assert_eq!(bills_window.observations.len(), 3); - assert!(bills_window.evidence.mismatches.is_empty()); - - let inventory: bridge_tally_core::InventoryBatch = serde_json::from_value(json!({ - "stock_items": [{ - "source_id": "item:1", - "name": "Synthetic Item", - "base_unit": "nos" - }], - "godowns": [{ - "source_id": "godown:1", - "name": "Synthetic Location" - }], - "inventory_entries": [{ - "source_id": "inventory:1", - "voucher_source_id": "voucher:1", - "stock_item_source_id": "missing-item", - "godown_source_id": "godown:1", - "quantity": "2.000", - "rate": "500.00", - "amount": "999.99" - }] - })) - .unwrap(); - let inventory_window = canonicalize_typed( - CapabilityPackId::Inventory, - PackBatch::Inventory(inventory), - references, - ); - assert!(inventory_window - .evidence - .mismatches - .iter() - .any(|mismatch| mismatch.safe_reason_code == "stock_item_reference_missing")); - assert!(!inventory_window - .evidence - .mismatches - .iter() - .any(|mismatch| mismatch.safe_reason_code.contains("amount"))); - } -} +#[path = "reconciliation_tests.rs"] +mod tests; diff --git a/src-tauri/src/sync/reconciliation_tests.rs b/src-tauri/src/sync/reconciliation_tests.rs new file mode 100644 index 00000000..1afb37f7 --- /dev/null +++ b/src-tauri/src/sync/reconciliation_tests.rs @@ -0,0 +1,865 @@ +use bridge_tally_core::{ + source_count_scope_fingerprint, ExactDecimal, LedgerEntryPolarity, LedgerEntryRecord, + LedgerRecord, ObservedSourceIdentities, RawSourceSha256, SourceAlterId, SourceRecordId, + SourceReportedCountEvidence, VoucherRecord, VoucherTypeRecord, +}; + +use super::*; +use serde_json::json; + +fn source_identity() -> SourceIdentity { + SourceIdentity { + bridge_source_lineage: "lineage-1".to_string(), + company_guid: "company-guid".to_string(), + observed_fingerprint: "fingerprint".to_string(), + } +} + +fn balanced_batch(reverse: bool) -> PackBatch { + let mut batch = CoreAccountingBatch { + ledgers: vec![ + LedgerRecord { + source_id: "ledger-b".to_string(), + name: "B".to_string(), + parent_source_id: None, + opening_balance: None, + }, + LedgerRecord { + source_id: "ledger-a".to_string(), + name: "A".to_string(), + parent_source_id: None, + opening_balance: None, + }, + ], + voucher_types: vec![VoucherTypeRecord { + source_id: "sales".to_string(), + name: "Sales".to_string(), + }], + vouchers: vec![VoucherRecord { + source_id: "voucher-1".to_string(), + date_yyyymmdd: "20260701".to_string(), + voucher_type_source_id: "sales".to_string(), + voucher_number: Some("1".to_string()), + cancelled: false, + optional: false, + }], + ledger_entries: vec![ + LedgerEntryRecord { + source_id: "entry-credit".to_string(), + voucher_source_id: "voucher-1".to_string(), + ledger_source_id: "ledger-b".to_string(), + amount: ExactDecimal::parse("-100.00").unwrap(), + polarity: LedgerEntryPolarity::Debit, + }, + LedgerEntryRecord { + source_id: "entry-debit".to_string(), + voucher_source_id: "voucher-1".to_string(), + ledger_source_id: "ledger-a".to_string(), + amount: ExactDecimal::parse("100").unwrap(), + polarity: LedgerEntryPolarity::Credit, + }, + ], + ..CoreAccountingBatch::default() + }; + if reverse { + batch.ledgers.reverse(); + batch.ledger_entries.reverse(); + } + PackBatch::CoreAccounting(batch) +} + +fn window() -> ReadWindow { + ReadWindow { + from_yyyymmdd: "20260701".to_string(), + to_yyyymmdd: "20260731".to_string(), + } +} + +fn query_profile() -> CanonicalText { + CanonicalText::parse("core-accounting-v1").unwrap() +} + +fn filters_sha256() -> CanonicalText { + CanonicalText::parse("a".repeat(64)).unwrap() +} + +fn canonicalize_test( + batch: PackBatch, + source_counts: Option>, +) -> CanonicalWindow { + canonicalize_window( + &CanonicalWindowContext { + requested_pack: CapabilityPackId::CoreAccounting, + schema_version: PackSchemaVersion { major: 1, minor: 0 }, + source_identity: &source_identity(), + query_profile: &query_profile(), + filters_sha256: &filters_sha256(), + external_references: &ExternalReferenceCatalog::Unavailable, + window_id: "window-1", + requested_window: &window(), + }, + &CanonicalPackWindow { + batch, + source_counts, + record_evidence: None, + }, + ) + .unwrap() +} + +fn complete_core_counts() -> Vec { + [ + ("group", 0), + ("ledger", 2), + ("voucher_type", 1), + ("voucher", 1), + ("ledger_entry", 2), + ] + .into_iter() + .map(|(object_type, source_reported_count)| { + let object_type = CanonicalText::parse(object_type).unwrap(); + let descriptor = SourceCountScopeDescriptor { + source_identity: source_identity(), + pack: CapabilityPackId::CoreAccounting, + pack_schema_version: PackSchemaVersion { major: 1, minor: 0 }, + object_type: object_type.clone(), + query_profile: query_profile(), + filters_sha256: filters_sha256(), + window: None, + }; + SourceReportedCountEvidence { + object_type, + query_profile: query_profile(), + source_scope_fingerprint: source_count_scope_fingerprint( + &descriptor, + SourceCountScope::Complete, + ) + .unwrap(), + source_count_scope: SourceCountScope::Complete, + source_reported_count, + } + }) + .collect() +} + +fn complete_core_record_evidence() -> Vec { + [ + ("ledger", "ledger-a", SourceIdentityKind::Guid, '1'), + ("ledger", "ledger-b", SourceIdentityKind::RemoteId, '2'), + ("voucher_type", "sales", SourceIdentityKind::Fallback, '3'), + ("voucher", "voucher-1", SourceIdentityKind::MasterId, '4'), + ( + "ledger_entry", + "entry-credit", + SourceIdentityKind::RemoteId, + '5', + ), + ("ledger_entry", "entry-debit", SourceIdentityKind::Guid, '6'), + ] + .into_iter() + .map(|(object_type, source_id, identity_kind, hash_char)| { + let source_id = SourceRecordId::parse(source_id).unwrap(); + let mut observed_identities = ObservedSourceIdentities::default(); + match identity_kind { + SourceIdentityKind::Guid => observed_identities.guid = Some(source_id.clone()), + SourceIdentityKind::RemoteId => observed_identities.remote_id = Some(source_id.clone()), + SourceIdentityKind::MasterId => observed_identities.master_id = Some(source_id.clone()), + SourceIdentityKind::Fallback => {} + } + SourceRecordEvidence { + object_type: CanonicalText::parse(object_type).unwrap(), + source_id: source_id.clone(), + identity_kind, + observed_identities, + raw_source_sha256: RawSourceSha256::parse(hash_char.to_string().repeat(64)).unwrap(), + alter_id: (source_id.as_str() == "voucher-1") + .then(|| SourceAlterId::parse("alter:77").unwrap()), + } + }) + .collect() +} + +fn canonicalize_typed( + pack: CapabilityPackId, + batch: PackBatch, + external_references: ExternalReferenceCatalog, +) -> CanonicalWindow { + canonicalize_window( + &CanonicalWindowContext { + requested_pack: pack, + schema_version: PackSchemaVersion { major: 1, minor: 0 }, + source_identity: &source_identity(), + query_profile: &CanonicalText::parse("typed-pack-v1").unwrap(), + filters_sha256: &filters_sha256(), + external_references: &external_references, + window_id: "window-1", + requested_window: &window(), + }, + &CanonicalPackWindow::without_source_count_evidence(batch), + ) + .unwrap() +} + +fn input(evidence: WindowEvidence) -> ReconciliationInput { + ReconciliationInput { + batch_id: "batch-1".to_string(), + run_id: "run-1".to_string(), + source_identity: source_identity(), + pack: CapabilityPackId::CoreAccounting, + pack_schema_version: PackSchemaVersion { major: 1, minor: 0 }, + started_at_unix_ms: 1_000, + completed_at_unix_ms: 2_000, + freshness_before: Freshness::NeverVerified, + freshness_target_seconds: 300, + planned_window_ids: BTreeSet::from(["window-1".to_string()]), + completed_windows: BTreeMap::from([("window-1".to_string(), evidence)]), + end_profile_check: EndProfileCheck::Unavailable, + source_stability_check: SourceStabilityCheck::Unavailable, + explicit_gap_codes: BTreeSet::new(), + warning_codes: BTreeSet::new(), + } +} + +#[test] +fn canonical_hash_is_stable_when_source_order_changes() { + let first = canonicalize_test(balanced_batch(false), None); + let second = canonicalize_test(balanced_batch(true), None); + assert_eq!( + first.evidence.canonical_sha256, + second.evidence.canonical_sha256 + ); + assert_eq!(first.observations.len(), 6); + assert!(first.evidence.mismatches.is_empty()); +} + +#[test] +fn mirror_input_preserves_identity_kind_raw_hash_and_alter_id() { + let source_window = CanonicalPackWindow { + batch: balanced_batch(false), + source_counts: None, + record_evidence: Some(complete_core_record_evidence()), + }; + let canonical = canonicalize_window( + &CanonicalWindowContext { + requested_pack: CapabilityPackId::CoreAccounting, + schema_version: PackSchemaVersion { major: 1, minor: 0 }, + source_identity: &source_identity(), + query_profile: &query_profile(), + filters_sha256: &filters_sha256(), + external_references: &ExternalReferenceCatalog::Unavailable, + window_id: "window-1", + requested_window: &window(), + }, + &source_window, + ) + .unwrap(); + assert_eq!( + canonical.evidence.record_provenance_scope, + ComparisonScope::Complete + ); + let voucher = canonical + .observations + .iter() + .find(|record| record.object_type == "voucher") + .unwrap(); + let mirror = voucher.mirror_input("batch-1", 1_000).unwrap(); + assert_eq!(mirror.identity.master_id.as_deref(), Some("voucher-1")); + assert!(mirror.identity.guid.is_none()); + assert!(mirror.identity.remote_id.is_none()); + assert!(mirror.identity.fallback_fingerprint.is_none()); + assert_eq!(mirror.raw_source_sha256, "4".repeat(64)); + assert_ne!(mirror.raw_source_sha256, voucher.canonical_sha256); + assert_eq!(mirror.observed_alter_id.as_deref(), Some("alter:77")); +} + +#[test] +fn missing_or_mismatched_record_provenance_never_reaches_staging_as_fabricated_data() { + let missing = canonicalize_test(balanced_batch(false), None); + assert_eq!( + missing.evidence.record_provenance_scope, + ComparisonScope::Unavailable + ); + assert!(matches!( + missing.observations[0].mirror_input("batch-1", 1_000), + Err(ReconciliationError::RecordProvenanceUnavailable) + )); + let decision = build_reconciliation(input(missing.evidence)).unwrap(); + assert!(decision + .mirror_commit + .parts() + .gap_codes + .contains(&"record_provenance_unavailable".to_string())); + + let mut evidence = complete_core_record_evidence(); + evidence[0].object_type = CanonicalText::parse("group").unwrap(); + let result = canonicalize_window( + &CanonicalWindowContext { + requested_pack: CapabilityPackId::CoreAccounting, + schema_version: PackSchemaVersion { major: 1, minor: 0 }, + source_identity: &source_identity(), + query_profile: &query_profile(), + filters_sha256: &filters_sha256(), + external_references: &ExternalReferenceCatalog::Unavailable, + window_id: "window-1", + requested_window: &window(), + }, + &CanonicalPackWindow { + batch: balanced_batch(false), + source_counts: None, + record_evidence: Some(evidence), + }, + ); + assert!(matches!( + result, + Err(ReconciliationError::RecordEvidenceMismatch) + )); +} + +#[test] +fn exact_decimal_reconciliation_detects_imbalance_without_float_math() { + let mut batch = balanced_batch(false); + let PackBatch::CoreAccounting(core) = &mut batch else { + unreachable!() + }; + core.ledger_entries[0].amount = ExactDecimal::parse("-99.999").unwrap(); + let canonical = canonicalize_test(batch, None); + assert!(canonical + .evidence + .mismatches + .iter() + .any(|mismatch| mismatch.safe_reason_code == "voucher_entries_unbalanced")); + let decision = build_reconciliation(input(canonical.evidence)).unwrap(); + assert_eq!(decision.proof.verification, CoreVerificationState::Partial); + assert!(decision.mirror_commit.parts().checkpoint_after.is_none()); + assert!(decision + .mirror_commit + .parts() + .gap_codes + .contains(&"reconciliation_mismatch".to_string())); +} + +#[test] +fn tally_polarity_is_independently_reconciled_from_signed_amounts() { + let mut batch = balanced_batch(false); + let PackBatch::CoreAccounting(core) = &mut batch else { + unreachable!() + }; + // Invert the WHOLE voucher, not one entry of it. Since bridge#392 a + // single disagreeing entry is contextual polarity, not a mismatch -- + // it is what an ordinary round-off leg looks like and it fired 111 + // times on one real book's financial year. A voucher every entry of + // which is inverted cannot be explained that way, and that is the + // shape this test needs: it is asserting that such a mismatch + // propagates to Partial verification, not that one flipped entry is + // individually detectable. + core.ledger_entries[0].polarity = LedgerEntryPolarity::Credit; + core.ledger_entries[1].polarity = LedgerEntryPolarity::Debit; + let canonical = canonicalize_test(batch, None); + assert!(canonical + .evidence + .mismatches + .iter() + .any(|mismatch| { mismatch.safe_reason_code == "voucher_entry_polarity_mismatch" })); + let decision = build_reconciliation(input(canonical.evidence)).unwrap(); + assert_eq!(decision.proof.verification, CoreVerificationState::Partial); + assert!(decision.mirror_commit.parts().checkpoint_after.is_none()); + assert!(decision + .mirror_commit + .parts() + .gap_codes + .contains(&"reconciliation_mismatch".to_string())); +} + +#[test] +fn cancelled_empty_voucher_is_not_a_false_missing_entry_failure() { + let mut batch = balanced_batch(false); + let PackBatch::CoreAccounting(core) = &mut batch else { + unreachable!() + }; + core.vouchers[0].cancelled = true; + core.ledger_entries.clear(); + let canonical = canonicalize_test(batch, None); + assert!(!canonical + .evidence + .accounting_gap_codes + .contains("voucher_entry_applicability_unavailable")); + assert!(!canonical + .evidence + .mismatches + .iter() + .any(|mismatch| mismatch.safe_reason_code == "voucher_entries_missing")); +} + +#[test] +fn unknown_non_cancelled_empty_voucher_applicability_is_a_proof_gap() { + let mut batch = balanced_batch(false); + let PackBatch::CoreAccounting(core) = &mut batch else { + unreachable!() + }; + core.ledger_entries.clear(); + let canonical = canonicalize_test(batch, None); + let decision = build_reconciliation(input(canonical.evidence)).unwrap(); + assert!(decision + .mirror_commit + .parts() + .gap_codes + .contains(&"voucher_entry_applicability_unavailable".to_string())); + assert_eq!(decision.proof.verification, CoreVerificationState::Partial); + assert!(decision.mirror_commit.parts().checkpoint_after.is_none()); +} + +#[test] +fn zero_amount_polarity_unavailability_prevents_checkpoint() { + let mut batch = balanced_batch(false); + let PackBatch::CoreAccounting(core) = &mut batch else { + unreachable!() + }; + core.ledger_entries[0].amount = ExactDecimal::parse("-0.00").unwrap(); + core.ledger_entries[1].amount = ExactDecimal::parse("0").unwrap(); + let canonical = canonicalize_test(batch, None); + let decision = build_reconciliation(input(canonical.evidence)).unwrap(); + assert!(decision + .mirror_commit + .parts() + .gap_codes + .contains(&"voucher_entry_polarity_unavailable".to_string())); + assert_eq!(decision.proof.verification, CoreVerificationState::Partial); + assert!(decision.mirror_commit.parts().checkpoint_after.is_none()); +} + +#[test] +fn missing_window_and_mismatch_can_never_create_a_checkpoint() { + let canonical = canonicalize_test(balanced_batch(false), None); + let mut input = input(canonical.evidence); + input.planned_window_ids.insert("window-2".to_string()); + let decision = build_reconciliation(input).unwrap(); + assert_eq!(decision.proof.verification, CoreVerificationState::Partial); + assert_eq!(decision.mirror_commit.parts().checkpoint_after, None); + assert!(decision + .mirror_commit + .parts() + .gap_codes + .contains(&"missing_snapshot_window".to_string())); +} + +#[test] +fn complete_counts_cannot_claim_verified_without_required_report_tie_out() { + let canonical = canonicalize_test(balanced_batch(false), Some(complete_core_counts())); + let first = build_reconciliation(input(canonical.evidence.clone())).unwrap(); + let second = build_reconciliation(input(canonical.evidence)).unwrap(); + assert_eq!(first.proof.verification, CoreVerificationState::Partial); + assert_eq!( + first.proof.snapshot_sha256, second.proof.snapshot_sha256, + "unchanged canonical state must hash identically" + ); + assert!(first.mirror_commit.parts().checkpoint_after.is_none()); + assert!(first + .mirror_commit + .parts() + .gap_codes + .contains(&"report_tie_out_unavailable".to_string())); + assert!(first + .mirror_commit + .parts() + .gap_codes + .contains(&"source_cut_consistency_unavailable".to_string())); + assert!(first + .mirror_commit + .parts() + .gap_codes + .contains(&"capability_profile_drift_check_unavailable".to_string())); +} + +#[test] +fn fresh_profile_and_full_reread_evidence_are_distinguished_from_atomicity() { + let canonical = canonicalize_test(balanced_batch(false), Some(complete_core_counts())); + let mut evidence = input(canonical.evidence); + evidence.end_profile_check = EndProfileCheck::Passed; + evidence.source_stability_check = SourceStabilityCheck::Passed; + let decision = build_reconciliation(evidence).unwrap(); + let gaps = &decision.mirror_commit.parts().gap_codes; + assert!(!gaps.contains(&"capability_profile_drift_check_unavailable".to_string())); + assert!(!gaps.contains(&"source_cut_consistency_unavailable".to_string())); + assert!(gaps.contains(&"source_cut_atomicity_unavailable".to_string())); + assert!(decision.mirror_commit.parts().checkpoint_after.is_none()); +} + +#[test] +fn end_profile_drift_is_a_proof_mismatch_not_cached_as_passed() { + let canonical = canonicalize_test(balanced_batch(false), Some(complete_core_counts())); + let mut evidence = input(canonical.evidence); + evidence.end_profile_check = EndProfileCheck::Mismatch; + let decision = build_reconciliation(evidence).unwrap(); + assert!(decision + .mirror_commit + .parts() + .gap_codes + .contains(&"capability_profile_changed_during_run".to_string())); + assert!(decision.mirror_commit.parts().checkpoint_after.is_none()); +} + +#[test] +fn missing_source_count_and_changed_identity_across_windows_fail_closed() { + let first = canonicalize_test(balanced_batch(false), None); + let unavailable = build_reconciliation(input(first.evidence.clone())).unwrap(); + assert_eq!( + unavailable.proof.verification, + CoreVerificationState::Partial + ); + assert!(unavailable + .mirror_commit + .parts() + .gap_codes + .contains(&"source_count_unavailable".to_string())); + assert!(unavailable.mirror_commit.parts().checkpoint_after.is_none()); + + let mut second_evidence = first.evidence.clone(); + second_evidence.window_id = "window-2".to_string(); + second_evidence.from_yyyymmdd = "20260801".to_string(); + second_evidence.to_yyyymmdd = "20260831".to_string(); + let identity = second_evidence + .canonical_records + .keys() + .next() + .expect("at least one record") + .clone(); + second_evidence + .canonical_records + .insert(identity, "f".repeat(64)); + let mut changed = input(first.evidence); + changed.planned_window_ids.insert("window-2".to_string()); + changed + .completed_windows + .insert("window-2".to_string(), second_evidence); + let decision = build_reconciliation(changed).unwrap(); + assert!(decision + .mirror_commit + .parts() + .gap_codes + .contains(&"source_changed_during_snapshot".to_string())); + assert!(decision.mirror_commit.parts().checkpoint_after.is_none()); +} + +#[test] +fn complete_count_once_covers_global_scope_without_per_window_repetition() { + let first = canonicalize_test(balanced_batch(false), Some(complete_core_counts())); + let second_window = ReadWindow { + from_yyyymmdd: "20260801".to_string(), + to_yyyymmdd: "20260831".to_string(), + }; + let second = canonicalize_window( + &CanonicalWindowContext { + requested_pack: CapabilityPackId::CoreAccounting, + schema_version: PackSchemaVersion { major: 1, minor: 0 }, + source_identity: &source_identity(), + query_profile: &query_profile(), + filters_sha256: &filters_sha256(), + external_references: &ExternalReferenceCatalog::Unavailable, + window_id: "window-2", + requested_window: &second_window, + }, + &CanonicalPackWindow::without_source_count_evidence(PackBatch::CoreAccounting( + CoreAccountingBatch::default(), + )), + ) + .unwrap(); + let mut reconciliation = input(first.evidence); + reconciliation + .planned_window_ids + .insert("window-2".to_string()); + reconciliation + .completed_windows + .insert("window-2".to_string(), second.evidence); + let decision = build_reconciliation(reconciliation).unwrap(); + assert_eq!(decision.proof.verification, CoreVerificationState::Partial); + assert!(decision + .mirror_commit + .parts() + .gap_codes + .contains(&"report_tie_out_unavailable".to_string())); + assert!(!decision + .mirror_commit + .parts() + .gap_codes + .iter() + .any(|code| code.starts_with("source_count_"))); +} + +#[test] +fn mismatch_drill_down_never_retains_raw_printable_source_ids() { + let raw_source_id = "CUSTOMER-LEDGER-PRINTABLE-123"; + let mismatch = safe_mismatch( + "synthetic_reference_missing", + vec![raw_source_id.to_string()], + ); + let encoded = serde_json::to_string(&mismatch).unwrap(); + assert!(!encoded.contains(raw_source_id)); + assert_eq!(mismatch.safe_record_ids.len(), 1); + assert!(mismatch.safe_record_ids[0].starts_with("rid:")); + assert_eq!(mismatch.safe_record_ids[0].len(), 68); +} + +#[test] +fn terminal_proofs_never_advance_a_checkpoint() { + for kind in [TerminalKind::Failed, TerminalKind::Cancelled] { + let decision = build_terminal_proof( + "batch-1".to_string(), + "run-1".to_string(), + source_identity(), + CapabilityPackId::CoreAccounting, + PackSchemaVersion { major: 1, minor: 0 }, + 1_000, + 2_000, + Freshness::Fresh, + 300, + kind, + "window_extract_failed".to_string(), + BTreeSet::from(["earlier_gap".to_string()]), + BTreeSet::from([WarningCode::AdaptiveWindowSplit]), + BTreeMap::from([ + ("locally_staged.accepted".to_string(), 2), + ("locally_staged.rejected".to_string(), 1), + ]), + ); + assert_eq!(decision.mirror_commit.parts().checkpoint_after, None); + assert_eq!( + decision.proof.verification, + CoreVerificationState::Unverified + ); + assert_eq!( + decision.mirror_commit.parts().gap_codes, + vec!["earlier_gap", "window_extract_failed"] + ); + assert_eq!( + decision.mirror_commit.parts().warning_codes, + vec!["adaptive_window_split"] + ); + assert_eq!( + decision + .proof + .gaps + .iter() + .map(|gap| gap.safe_reason_code.as_str()) + .collect::>(), + vec!["earlier_gap", "window_extract_failed"] + ); + assert_eq!(decision.proof.record_counts["locally_staged.accepted"], 2); + assert_eq!(decision.proof.record_counts["locally_staged.rejected"], 1); + assert_eq!( + decision.mirror_commit.parts().record_counts_sha256, + Some(proof_record_counts_sha256(&decision.proof.record_counts)) + ); + } +} + +#[test] +fn terminal_proof_clamps_backward_clock_and_records_the_gap() { + let decision = build_terminal_proof( + "batch-clock".to_string(), + "run-clock".to_string(), + source_identity(), + CapabilityPackId::CoreAccounting, + PackSchemaVersion { major: 1, minor: 0 }, + 2_000, + 1_999, + Freshness::NeverVerified, + 300, + TerminalKind::Failed, + "source_outcome_unknown".to_string(), + BTreeSet::new(), + BTreeSet::new(), + BTreeMap::new(), + ); + assert_eq!(decision.proof.completed_at_unix_ms, Some(2_000)); + assert_eq!(decision.mirror_commit.parts().completed_at_unix_ms, 2_000); + assert_eq!( + decision.mirror_commit.parts().gap_codes, + vec!["local_clock_moved_backwards", "source_outcome_unknown"] + ); +} + +#[test] +fn source_count_scope_fingerprint_mismatch_is_rejected_before_staging() { + let mut counts = complete_core_counts(); + counts[0].source_scope_fingerprint = CanonicalText::parse("b".repeat(64)).unwrap(); + let result = canonicalize_window( + &CanonicalWindowContext { + requested_pack: CapabilityPackId::CoreAccounting, + schema_version: PackSchemaVersion { major: 1, minor: 0 }, + source_identity: &source_identity(), + query_profile: &query_profile(), + filters_sha256: &filters_sha256(), + external_references: &ExternalReferenceCatalog::Unavailable, + window_id: "window-1", + requested_window: &window(), + }, + &CanonicalPackWindow { + batch: balanced_batch(false), + source_counts: Some(counts), + record_evidence: None, + }, + ); + assert!(matches!( + result, + Err(ReconciliationError::SourceCountScopeMismatch) + )); +} + +#[test] +fn typed_packs_preserve_exact_values_and_enforce_reference_integrity() { + let references = ExternalReferenceCatalog::Complete { + company_ids: BTreeSet::from(["company-guid".to_string()]), + voucher_ids: BTreeSet::from(["voucher:1".to_string(), "voucher:2".to_string()]), + ledger_ids: BTreeSet::from(["ledger:customer".to_string()]), + }; + let tax = bridge_tally_core::IndiaTaxBatch { + tax_registrations: vec![serde_json::from_value(json!({ + "source_id": "tax-registration:1", + "owner_kind": "ledger", + "owner_source_id": "ledger:customer", + "registration_type": "regular", + "gstin": "27ABCDE1234F1Z5" + })) + .unwrap()], + voucher_taxes: vec![serde_json::from_value(json!({ + "source_id": "voucher-tax:1", + "voucher_source_id": "voucher:1", + "place_of_supply": "27", + "assessable_value": "1000.00", + "tax_component": "igst", + "tax_rate": "18.00", + // Deliberately not assessable*rate/100: no rounding/formula profile exists. + "tax_amount": "179.99" + })) + .unwrap()], + }; + let tax_window = canonicalize_typed( + CapabilityPackId::IndiaTax, + PackBatch::IndiaTax(tax), + references.clone(), + ); + assert_eq!(tax_window.observations.len(), 2); + assert!(tax_window.evidence.mismatches.is_empty()); + assert_eq!( + tax_window.observations[1] + .exact_decimals + .get("tax_amount") + .map(String::as_str), + Some("179.99") + ); + + let bills: bridge_tally_core::BillsAndPaymentsBatch = serde_json::from_value(json!({ + "parties": [{ + "source_identity": { + "bridge_source_lineage": "bridge-source:test", + "company_guid": "company-guid:test", + "observed_fingerprint": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "party_ledger_source_id": "ledger:customer", + "report_as_of_yyyymmdd": "20260731", + "direction": "receivable", + "bill_wise_state": "enabled_observed", + "allocation_coverage": "observed_complete_scope", + "outstanding_coverage": "observed_complete_scope", + "fetch_bracket": "stable_observed", + "query_profile": "bills-confidence-v1", + "source_scope_fingerprint": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "source_reported_allocation_count": 1, + "source_reported_outstanding_count": 1, + "allocations": [{ + "source_id": "bill:1", + "identity_basis": "parent_ordinal", + "origin": { + "origin": "voucher", + "voucher_source_id": "voucher:1", + "party_entry_source_id": "entry:party-1" + }, + "reference": { + "kind": "new_reference", + "name": "INV-1", + "raw_kind": null + }, + "bill_date_yyyymmdd": "20260701", + "effective_date_yyyymmdd": null, + "due_date_yyyymmdd": "20260731", + "due_date_evidence": "explicit", + "amount": "-1180.00", + "observed_polarity": "debit", + "currency_basis": { + "basis": "company_base", + "currency": "company-base" + } + }], + "outstanding": [{ + "source_id": "outstanding:1", + "identity_basis": "parent_ordinal", + "origin": { + "origin": "voucher", + "voucher_source_id": "voucher:1" + }, + "reference": { + "kind": "new_reference", + "name": "INV-1", + "raw_kind": null + }, + "bill_date_yyyymmdd": "20260701", + "effective_date_yyyymmdd": null, + "due_date_yyyymmdd": "20260731", + "due_date_evidence": "explicit", + "opening_amount": "-1180.00", + "pending_amount": "-1180.00", + "observed_polarity": "debit", + "source_reported_overdue_days": 0, + "currency_basis": { + "basis": "company_base", + "currency": "company-base" + } + }] + }] + })) + .unwrap(); + let bills_window = canonicalize_typed( + CapabilityPackId::BillsAndPayments, + PackBatch::BillsAndPayments(bills), + references.clone(), + ); + assert_eq!(bills_window.observations.len(), 3); + assert!(bills_window.evidence.mismatches.is_empty()); + + let inventory: bridge_tally_core::InventoryBatch = serde_json::from_value(json!({ + "stock_items": [{ + "source_id": "item:1", + "name": "Synthetic Item", + "base_unit": "nos" + }], + "godowns": [{ + "source_id": "godown:1", + "name": "Synthetic Location" + }], + "inventory_entries": [{ + "source_id": "inventory:1", + "voucher_source_id": "voucher:1", + "stock_item_source_id": "missing-item", + "godown_source_id": "godown:1", + "quantity": "2.000", + "rate": "500.00", + "amount": "999.99" + }] + })) + .unwrap(); + let inventory_window = canonicalize_typed( + CapabilityPackId::Inventory, + PackBatch::Inventory(inventory), + references, + ); + assert!(inventory_window + .evidence + .mismatches + .iter() + .any(|mismatch| mismatch.safe_reason_code == "stock_item_reference_missing")); + assert!(!inventory_window + .evidence + .mismatches + .iter() + .any(|mismatch| mismatch.safe_reason_code.contains("amount"))); +}