//! Desktop D3: studios, store app keys and store-only targets, pairing / ignore overrides, settings, old state files. use serde_json::json; use storebound_desktop::cache::Cache; use storebound_desktop::policy::{sanitize_js_call, Kind}; use storebound_desktop::store::{self, State}; use storebound_desktop::workspace::*; const A1: &str = "0123456789abcdef"; const A2: &str = "fedcba9876543210"; #[test] fn studio_names_colours_and_logos() { assert_eq!(check_studio_name(" Example Games ").unwrap(), "Example Games"); assert!(check_studio_name("").is_err()); assert!(check_studio_name(&"x".repeat(49)).is_err()); assert!(check_studio_name("a\nb").is_err()); for c in STUDIO_COLORS { assert!(check_color(c).is_ok()); } assert!(check_color("#ff0000").is_err()); assert!(check_color("red; background:url(x)").is_err()); assert!(logo_data_url(&[0x89, b'P', b'N', b'G', 1, 2]).unwrap().starts_with("data:image/png;base64,")); assert!(logo_data_url(&[0xff, 0xd8, 0xff, 0]).unwrap().starts_with("data:image/jpeg;base64,")); assert!(logo_data_url(b"RIFF\0\0\0\0WEBPVP8 ").unwrap().starts_with("data:image/webp;")); assert!(logo_data_url(b"").is_err(), "no SVG (scripts) as a logo"); assert!(logo_data_url(&vec![0x89; LOGO_MAX + 1]).is_err()); } #[test] fn store_app_keys() { assert_eq!(StoreAppKey::parse(&format!("ios:{A1}:6400000101")).unwrap(), StoreAppKey::Ios { account: A1.into(), app_id: "6400000101".into() }); assert_eq!(StoreAppKey::parse(&format!("android:{A2}:com.example.puzzle")).unwrap().account(), A2); for bad in [ format!("ios:{A1}:com.example.puzzle"), format!("android:{A1}:6400000101"), "ios:nothex0123456789:1".to_string(), format!("mac:{A1}:1"), format!("android:{A1}:com.example.puzzle/../x"), format!("android:{A1}:.example"), format!("ios:{}:1", A1.to_uppercase()), ] { assert!(StoreAppKey::parse(&bad).is_err(), "{bad}"); } } #[test] fn store_only_targets_become_virtual_projects_with_store_ids_only() { let t = StoreTarget::parse(&format!("store:ios:{A1}:6400000101:com.example.puzzle;android:{A2}:com.example.puzzle")).unwrap(); assert_eq!(t.accounts(), vec![A1, A2]); assert_eq!( t.virtual_project(), json!({ "name": "com.example.puzzle", "apple": { "bundleId": "com.example.puzzle", "appId": "6400000101" }, "play": { "packageName": "com.example.puzzle" } }) ); let only = StoreTarget::parse(&format!("store:android:{A2}:com.example.runner")).unwrap(); assert_eq!(only.virtual_project(), json!({ "name": "com.example.runner", "play": { "packageName": "com.example.runner" } })); for bad in [ "store:".to_string(), "ios:x".to_string(), format!("store:ios:{A1}:6400000101"), format!("store:ios:{A1}:6400000101:com.example.a;ios:{A1}:6400000102:com.example.b"), format!("store:android:{A2}:com.example.a;android:{A2}:com.example.b"), format!("store:android:{A2}:com.example.a;path:/etc"), format!("store:ios:{A1}:6400000101:../../etc"), format!("store:android:{A2}:com.example.a;{}", "x".repeat(400)), ] { assert!(StoreTarget::parse(&bad).is_err(), "{bad}"); } } #[test] fn overrides_are_checked_entry_by_entry() { let ok = Overrides { pairs: vec![Pair { ios: format!("ios:{A1}:6400000101"), android: format!("android:{A2}:com.example.puzzle") }], unpairs: vec![Pair { ios: format!("ios:{A1}:6400000102"), android: format!("android:{A2}:com.example.notes") }], links: vec![Link { key: format!("ios:{A1}:6400000103"), project: "p1".into() }], app_studio: vec![AppStudio { key: format!("android:{A2}:com.example.runner"), studio: "s1".into() }, AppStudio { key: "project:p1".into(), studio: "s2".into() }], ignored: vec![format!("app:ios:{A1}:6400000104"), format!("account:{A2}"), "project:p9".into()], }; assert!(check_overrides(&ok).is_ok()); let mut swapped = ok.clone(); swapped.pairs = vec![Pair { ios: format!("android:{A2}:com.example.puzzle"), android: format!("ios:{A1}:6400000101") }]; assert!(check_overrides(&swapped).is_err()); for bad in ["app:ios:x:1", "account:short", "project:", "everything", "app:"] { let mut o = ok.clone(); o.ignored.push(bad.into()); assert!(check_overrides(&o).is_err(), "{bad}"); } let many = Overrides { ignored: (0..=MAX_OVERRIDES).map(|i| format!("project:p{i}")).collect(), ..Default::default() }; assert!(check_overrides(&many).is_err()); // camelCase on the wire let v = serde_json::to_value(&ok).unwrap(); assert!(v.get("appStudio").is_some()); } #[test] fn settings_have_safe_defaults_and_bounds() { let d = Settings::default(); assert!(check_settings(&d).is_ok()); assert_eq!((d.refresh.auto_minutes, d.refresh.on_launch, d.refresh.parallel, d.cache.keep_days, d.channel.as_str()), (0, false, 4, 30, "stable")); let mut s = d.clone(); s.refresh.parallel = 0; assert!(check_settings(&s).is_err()); s = d.clone(); s.refresh.parallel = 9; assert!(check_settings(&s).is_err()); s = d.clone(); s.refresh.auto_minutes = 1; assert!(check_settings(&s).is_err(), "no tight polling of the store APIs"); assert_eq!((d.refresh.auto_read, d.refresh.every_minutes), (true, 15), "background reading is on by default, every 15 minutes"); s = d.clone(); s.refresh.every_minutes = 1; assert!(check_settings(&s).is_err(), "no background pass more often than every 5 minutes"); s = d.clone(); s.refresh.every_minutes = 0; assert!(check_settings(&s).is_ok()); let old: Settings = serde_json::from_value(serde_json::json!({ "refresh": { "autoMinutes": 15 } })).unwrap(); assert_eq!((old.refresh.auto_read, old.refresh.every_minutes, old.refresh.auto_minutes), (true, 15, 15), "an older settings file gets the new defaults"); s = d.clone(); s.cache.keep_days = 3; assert!(check_settings(&s).is_err()); s = d; s.channel = "nightly".into(); assert!(check_settings(&s).is_err()); let v = serde_json::to_value(Settings::default()).unwrap(); assert_eq!(v["refresh"]["autoMinutes"], 0); assert_eq!(v["cache"]["keepDays"], 30); assert_eq!(v["notify"]["lowReviews"], true); } #[test] fn a_d2_state_file_still_loads_with_empty_d3_parts() { let dir = tempfile::tempdir().unwrap(); let d2 = json!({ "projects": [{ "id": "p1", "path": "/x", "name": "X", "playAccount": null, "appleAccount": null, "added": "2026-10-01T00:00:00Z" }], "accounts": [{ "id": A1, "label": "Play", "kind": "play", "created": "2026-10-01T00:00:00Z" }] }); std::fs::write(dir.path().join(store::FILE), d2.to_string()).unwrap(); let st = store::load(dir.path()); assert_eq!(st.projects.len(), 1); assert_eq!(st.accounts[0].studio, None); assert!(st.studios.is_empty()); assert_eq!(st.overrides, Overrides::default()); assert_eq!(st.settings, Settings::default()); store::save(dir.path(), &st).unwrap(); assert_eq!(store::load(dir.path()), st); assert_eq!(State::default().settings.refresh.parallel, 4); } #[test] fn account_reads_are_the_hosts() { // the window cannot call apps.list / account.check directly: only the account commands (one key per call) assert!(sanitize_js_call("apps.list", Some(Kind::Account), &json!({ "credentials": {} })).is_err()); assert!(sanitize_js_call("account.check", Some(Kind::Account), &json!({})).is_err()); assert_eq!(Kind::parse("account"), Some(Kind::Account)); } #[test] fn cache_info_and_pruning_by_age() { let c = Cache::in_memory().unwrap(); c.put("p", "health", &json!({}), &json!({ "n": 1 }), "2026-01-01T00:00:00Z").unwrap(); c.put("p", "reviews.list", &json!({}), &json!({ "n": 2 }), "2026-10-01T00:00:00Z").unwrap(); c.put("acct:x", "apps.list", &json!({}), &json!({ "apps": [] }), "2026-10-10T00:00:00Z").unwrap(); let (rows, bytes, oldest) = c.info().unwrap(); assert_eq!((rows, oldest.as_deref()), (3, Some("2026-01-01T00:00:00Z"))); assert!(bytes > 0); assert_eq!(c.prune_before("2026-09-01T00:00:00Z").unwrap(), 1); assert!(c.get("p", "health", &json!({})).unwrap().is_none()); assert!(c.get("p", "reviews.list", &json!({})).unwrap().is_some()); }