Skip to main content

max / audiofiles

3.6 KB · 107 lines History Blame Raw
1 //! Tests for the config store and its sync export boundary.
2 //!
3 //! Extracted from the former `db.rs` inline test module.
4
5 use crate::db::*;
6
7 #[test]
8 fn user_config_export_trigger_excludes_device_local_keys() {
9 // Export side of the CHRONIC fix (fuzz-2026-07-21 #3): the sync triggers
10 // are generated from the ConfigKey registry via config_key_policy, so a
11 // device-local key never enqueues a changelog row, while a replicated
12 // key still does. Symmetric with the import-side test in audiofiles-sync.
13 let db = Database::open_in_memory().unwrap();
14 let conn = db.conn();
15 let changelog_rows = |key: &str| -> i64 {
16 conn.query_row(
17 "SELECT COUNT(*) FROM sync_changelog WHERE table_name = 'user_config' AND row_id = ?1",
18 [key],
19 |r| r.get(0),
20 )
21 .unwrap()
22 };
23
24 for key in [
25 "mirror_path",
26 "mirror_enabled",
27 "import_preflight_disabled",
28 "loose_files",
29 ] {
30 conn.execute(
31 "INSERT OR REPLACE INTO user_config (key, value) VALUES (?1, '1')",
32 [key],
33 )
34 .unwrap();
35 assert_eq!(changelog_rows(key), 0, "{key} must not be exported");
36 }
37
38 // A replicated key still enqueues a changelog row.
39 conn.execute(
40 "INSERT OR REPLACE INTO user_config (key, value) VALUES ('theme', 'dark')",
41 [],
42 )
43 .unwrap();
44 assert_eq!(changelog_rows("theme"), 1, "theme must be exported");
45 }
46
47 // The same export boundary, exercised through the real write path the app
48 // uses now: `Database::set_config` over the shared `ConfigStore`, not a raw
49 // INSERT. The store's upsert and the spec-seeded policy must still keep a
50 // device-local key off the changelog while a replicated key lands.
51 #[test]
52 fn set_config_respects_the_export_boundary() {
53 let db = Database::open_in_memory().unwrap();
54 let changelog_rows = |key: &str| -> i64 {
55 db.conn()
56 .query_row(
57 "SELECT COUNT(*) FROM sync_changelog WHERE table_name = 'user_config' AND row_id = ?1",
58 [key],
59 |r| r.get(0),
60 )
61 .unwrap()
62 };
63
64 db.set_config(ConfigKey::MirrorPath, "/mnt/samples")
65 .unwrap();
66 assert_eq!(
67 db.get_config(ConfigKey::MirrorPath).unwrap().as_deref(),
68 Some("/mnt/samples"),
69 "a device-local key is still stored locally",
70 );
71 assert_eq!(
72 changelog_rows("mirror_path"),
73 0,
74 "a device-local key must never be exported",
75 );
76
77 db.set_config(ConfigKey::Theme, "dark").unwrap();
78 assert_eq!(changelog_rows("theme"), 1, "a replicated key is exported");
79 }
80
81 // The reason the store upserts instead of `INSERT OR REPLACE`: rewriting a
82 // replicated key is one UPDATE, not a DELETE-then-INSERT pair. The old
83 // path enqueued a spurious delete for every re-save of a synced setting.
84 #[test]
85 fn rewriting_a_synced_key_enqueues_an_update_not_a_delete() {
86 let db = Database::open_in_memory().unwrap();
87 db.set_config(ConfigKey::Theme, "light").unwrap();
88 db.set_config(ConfigKey::Theme, "dark").unwrap();
89
90 let ops: Vec<String> = db
91 .conn()
92 .prepare(
93 "SELECT op FROM sync_changelog WHERE table_name = 'user_config' AND row_id = 'theme' ORDER BY id",
94 )
95 .unwrap()
96 .query_map([], |r| r.get(0))
97 .unwrap()
98 .collect::<Result<_, _>>()
99 .unwrap();
100
101 assert_eq!(ops, vec!["INSERT", "UPDATE"], "one insert then one update");
102 assert!(
103 !ops.iter().any(|op| op == "DELETE"),
104 "no spurious delete from a re-save",
105 );
106 }
107