Skip to content

Commit e505963

Browse files
committed
refactor: always include walletId in current activity payloads
Every serialized activity record includes walletId, including the default "bitkit" wallet, while legacy JSON without walletId still decodes to the default via the serde default. A freshly exported default-wallet payload is therefore never indistinguishable from legacy data. Activity identity is scoped by the composite primary key (wallet_id, id), so normal query APIs return only the canonical shape and lookup, list, search, tags, seen state, and transaction details stay wallet-scoped without mixed or duplicate entries for the same logical activity. Add tests covering legacy v1 decode, current-shape serialization always carrying walletId, and a v1/v2 pair sharing one raw id staying wallet-scoped on lookup, list, and search. The broader backup/import/export and DB migration work is deferred to #113. Resolves #112
1 parent 7c60588 commit e505963

1 file changed

Lines changed: 235 additions & 0 deletions

File tree

src/modules/activity/tests.rs

Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -853,6 +853,241 @@ mod tests {
853853
cleanup(&db_path);
854854
}
855855

856+
fn json_has_wallet_id<T: serde::Serialize>(value: &T) -> bool {
857+
serde_json::to_value(value)
858+
.unwrap()
859+
.get("wallet_id")
860+
.is_some()
861+
}
862+
863+
#[test]
864+
fn test_default_wallet_activity_always_serializes_wallet_id() {
865+
// The current/canonical payload always includes `wallet_id`, even for the default
866+
// wallet. A freshly serialized default-wallet record is therefore distinguishable
867+
// from legacy data, and round-trips back to the default wallet.
868+
let onchain = create_test_onchain_activity();
869+
assert_eq!(onchain.wallet_id, DEFAULT_WALLET_ID);
870+
assert!(
871+
json_has_wallet_id(&onchain),
872+
"default-wallet onchain activity must serialize wallet_id (current payload)"
873+
);
874+
let onchain_json = serde_json::to_string(&onchain).unwrap();
875+
let decoded: OnchainActivity = serde_json::from_str(&onchain_json).unwrap();
876+
assert_eq!(decoded.wallet_id, DEFAULT_WALLET_ID);
877+
878+
let lightning = create_test_lightning_activity();
879+
assert_eq!(lightning.wallet_id, DEFAULT_WALLET_ID);
880+
assert!(
881+
json_has_wallet_id(&lightning),
882+
"default-wallet lightning activity must serialize wallet_id (current payload)"
883+
);
884+
let lightning_json = serde_json::to_string(&lightning).unwrap();
885+
let decoded: LightningActivity = serde_json::from_str(&lightning_json).unwrap();
886+
assert_eq!(decoded.wallet_id, DEFAULT_WALLET_ID);
887+
}
888+
889+
#[test]
890+
fn test_old_v1_activity_json_without_wallet_id_decodes() {
891+
// Old JSON authored before wallet_id existed (no wallet_id key) must still decode,
892+
// defaulting to the built-in Bitkit wallet.
893+
let onchain_v1 = r#"{
894+
"id": "legacy_onchain",
895+
"tx_type": "Sent",
896+
"tx_id": "legacy_txid",
897+
"value": 5000,
898+
"fee": 50,
899+
"fee_rate": 1,
900+
"address": "bc1qlegacy",
901+
"confirmed": true,
902+
"timestamp": 1234567890,
903+
"is_boosted": false,
904+
"boost_tx_ids": [],
905+
"is_transfer": false,
906+
"does_exist": true,
907+
"confirm_timestamp": null,
908+
"channel_id": null,
909+
"transfer_tx_id": null
910+
}"#;
911+
let decoded: OnchainActivity = serde_json::from_str(onchain_v1).unwrap();
912+
assert_eq!(decoded.wallet_id, DEFAULT_WALLET_ID);
913+
assert_eq!(decoded.tx_id, "legacy_txid");
914+
915+
let lightning_v1 = r#"{
916+
"id": "legacy_lightning",
917+
"tx_type": "Received",
918+
"status": "Succeeded",
919+
"value": 7000,
920+
"fee": 3,
921+
"invoice": "lightning:legacy",
922+
"message": "legacy message",
923+
"timestamp": 1234567990,
924+
"preimage": null
925+
}"#;
926+
let decoded: LightningActivity = serde_json::from_str(lightning_v1).unwrap();
927+
assert_eq!(decoded.wallet_id, DEFAULT_WALLET_ID);
928+
assert_eq!(decoded.invoice, "lightning:legacy");
929+
}
930+
931+
#[test]
932+
fn test_hardware_wallet_activity_serializes_wallet_id() {
933+
// Wallet-scoped (non-default) records keep wallet_id in the JSON and round-trip it
934+
// back unchanged.
935+
let wallet_id = crate::activity::derive_wallet_id(
936+
"trezor".to_string(),
937+
vec!["xpubA".to_string(), "xpubB".to_string()],
938+
)
939+
.unwrap();
940+
941+
let mut onchain = create_test_onchain_activity();
942+
onchain.wallet_id = wallet_id.clone();
943+
assert!(json_has_wallet_id(&onchain));
944+
let decoded: OnchainActivity =
945+
serde_json::from_str(&serde_json::to_string(&onchain).unwrap()).unwrap();
946+
assert_eq!(decoded.wallet_id, wallet_id);
947+
948+
let mut lightning = create_test_lightning_activity();
949+
lightning.wallet_id = wallet_id.clone();
950+
assert!(json_has_wallet_id(&lightning));
951+
let decoded: LightningActivity =
952+
serde_json::from_str(&serde_json::to_string(&lightning).unwrap()).unwrap();
953+
assert_eq!(decoded.wallet_id, wallet_id);
954+
}
955+
956+
#[test]
957+
fn test_wallet_scoped_metadata_models_always_serialize_wallet_id() {
958+
// Tags, pre-activity metadata and transaction details gained wallet_id in the same
959+
// change; the current/canonical payload always carries wallet_id, for the default
960+
// wallet and for any other scope alike.
961+
let scoped = "hardware-wallet-1";
962+
963+
let default_tags = ActivityTags {
964+
wallet_id: DEFAULT_WALLET_ID.to_string(),
965+
activity_id: "act1".to_string(),
966+
tags: vec!["tag".to_string()],
967+
};
968+
assert!(json_has_wallet_id(&default_tags));
969+
let scoped_tags = ActivityTags {
970+
wallet_id: scoped.to_string(),
971+
..default_tags.clone()
972+
};
973+
assert!(json_has_wallet_id(&scoped_tags));
974+
975+
let default_meta = create_test_pre_activity_metadata(
976+
"pay1".to_string(),
977+
ActivityType::Onchain,
978+
vec!["tag".to_string()],
979+
);
980+
assert!(json_has_wallet_id(&default_meta));
981+
let scoped_meta = PreActivityMetadata {
982+
wallet_id: scoped.to_string(),
983+
..default_meta.clone()
984+
};
985+
assert!(json_has_wallet_id(&scoped_meta));
986+
987+
let default_details = TransactionDetails {
988+
wallet_id: DEFAULT_WALLET_ID.to_string(),
989+
tx_id: "txid".to_string(),
990+
amount_sats: 1000,
991+
inputs: vec![],
992+
outputs: vec![],
993+
};
994+
assert!(json_has_wallet_id(&default_details));
995+
let scoped_details = TransactionDetails {
996+
wallet_id: scoped.to_string(),
997+
..default_details.clone()
998+
};
999+
assert!(json_has_wallet_id(&scoped_details));
1000+
}
1001+
1002+
#[test]
1003+
fn test_mixed_v1_v2_lookup_and_search_is_wallet_scoped() {
1004+
// A v1 (default-wallet) and v2 (hardware-wallet) activity sharing the same raw id
1005+
// must remain distinct: wallet-scoped lookup, list and search each return only the
1006+
// matching record, never a mixed or duplicated v1/v2 pair.
1007+
let (mut db, db_path) = setup();
1008+
let wallet_id = "hardware-wallet-1";
1009+
let shared_id = "shared_raw_id";
1010+
1011+
let mut v1 = create_test_onchain_activity();
1012+
v1.id = shared_id.to_string();
1013+
v1.tx_id = "v1_txid".to_string();
1014+
v1.address = "bc1qv1default".to_string();
1015+
v1.value = 10_000;
1016+
1017+
let mut v2 = create_test_onchain_activity();
1018+
v2.wallet_id = wallet_id.to_string();
1019+
v2.id = shared_id.to_string();
1020+
v2.tx_id = "v2_txid".to_string();
1021+
v2.address = "bc1qv2hardware".to_string();
1022+
v2.value = 20_000;
1023+
1024+
db.insert_onchain_activity(&v1).unwrap();
1025+
db.insert_onchain_activity(&v2).unwrap();
1026+
1027+
let default_activity = db
1028+
.get_activity_by_id(DEFAULT_WALLET_ID, shared_id)
1029+
.unwrap()
1030+
.unwrap();
1031+
assert_eq!(default_activity.get_wallet_id(), DEFAULT_WALLET_ID);
1032+
1033+
let scoped_activity = db
1034+
.get_activity_by_id(wallet_id, shared_id)
1035+
.unwrap()
1036+
.unwrap();
1037+
assert_eq!(scoped_activity.get_wallet_id(), wallet_id);
1038+
1039+
// Wallet-scoped list returns exactly one record for each wallet, not the mixed pair.
1040+
let default_list = db
1041+
.get_activities(
1042+
Some(DEFAULT_WALLET_ID),
1043+
Some(ActivityFilter::Onchain),
1044+
None,
1045+
None,
1046+
None,
1047+
None,
1048+
None,
1049+
None,
1050+
None,
1051+
)
1052+
.unwrap();
1053+
assert_eq!(default_list.len(), 1);
1054+
assert_eq!(default_list[0].get_wallet_id(), DEFAULT_WALLET_ID);
1055+
1056+
let scoped_list = db
1057+
.get_activities(
1058+
Some(wallet_id),
1059+
Some(ActivityFilter::Onchain),
1060+
None,
1061+
None,
1062+
None,
1063+
None,
1064+
None,
1065+
None,
1066+
None,
1067+
)
1068+
.unwrap();
1069+
assert_eq!(scoped_list.len(), 1);
1070+
assert_eq!(scoped_list[0].get_wallet_id(), wallet_id);
1071+
1072+
// Search stays wallet-scoped: the hardware address is invisible to the default wallet.
1073+
let scoped_search = db
1074+
.get_activities(
1075+
Some(DEFAULT_WALLET_ID),
1076+
None,
1077+
None,
1078+
None,
1079+
Some("bc1qv2hardware".to_string()),
1080+
None,
1081+
None,
1082+
None,
1083+
None,
1084+
)
1085+
.unwrap();
1086+
assert!(scoped_search.is_empty());
1087+
1088+
cleanup(&db_path);
1089+
}
1090+
8561091
#[test]
8571092
fn test_delete_activities_by_wallet_id_cleans_scoped_data() {
8581093
let (mut db, db_path) = setup();

0 commit comments

Comments
 (0)