Skip to main content

max / goingson

Settings > Sync comes back: its reads were local all along Second of the six the swap took out. The section was held out on a count: 13 `.await` sites in commands/sync.rs. Counted per command instead, that number is the busiest control rather than what the data needs, which is the mistake quasi::settings::email's header already names having made once about OAuth: a section is not undescribable because its loudest feature is, and the test is what the data needs rather than what the busiest control does. sync_status is nearly the whole of what this section shows, and it is client.config(), client.session_info(), client.has_master_key() and two synchronous database reads. sync_update_settings, sync_disconnect and sync_start_auth have no awaits at all. So the readout and the settings are here: configured, signed in, server, encryption ready, device, last sync, waiting-to-send count, the auto-sync switch, the interval, and Disconnect. WHAT STAYS OUT, pinned by a test so it reads as a measurement rather than an unfinished section: Connect, Sync Now, Set up encryption, Subscribe, and the held-changes drill-in. Each is a conversation with a server. Same arrangement as Email, where the accounts are described and the handshake is not. Disconnect being here while Connect is not looks odd and is right. Deleting the stored token and telling the store to stop are both local, and a server that never hears about it is not a failure case: the token is what this device holds rather than a session the server tracks. THE TWO AWAITS IN sync_status ARE LEFT OUT RATHER THAN FETCHED. has_server_key() ends in .ok() and held_counts() "degrades to zero rather than failing the whole status call", so both are facts the shipped screen was content to be missing. Holding the last known value of each on AppState and saying how old it is would work and is 82273265 option (d) in miniature; two facts do not pay for it. A device with no sync client says so plainly instead of drawing a section of dashes and disabled switches. The settings themselves are writable either way, because they are sync_state rows the scheduler reads rather than anything the client owns, and that is the half that proves the section is local. The three writes are their own routes rather than arms of the config writer: these are sync_state rows, not user_config keys. "settings-content" is a const now, since four things aim at it. Two existing tests used /settings/sync as their example of an undescribed section. Sharing is that example now, and it is the genuine one: group_list, group_list_members and group_list_invitations all await, so there is no local state to draw a section from at all. It is the last consumer on quasicoherent 82273265, which is down from five and back to Medium. Seven tests. 783 total. Closes goingson 579a2e16.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-22 18:42 UTC
Signed with PGP, not checked
Commit: 8b6f45a2a7bfdcce9e94def435d88c3cf9621bd9
Parent: 044cf2d
6 files changed, +520 insertions, -24 deletions
M Cargo.lock +4 -4
@@ -8493,6 +8493,10 @@
8493 8493 "winnow 1.0.4",
8494 8494 ]
8495 8495
8496 + [[patch.unused]]
8497 + name = "ops-status"
8498 + version = "0.1.0"
8499 +
8496 8500 [[patch.unused]]
8497 8501 name = "quasi-axum"
8498 8502 version = "0.54.0"
@@ -8508,7 +8512,3 @@
8508 8512 [[patch.unused]]
8509 8513 name = "quasi-store"
8510 8514 version = "0.1.0"
8511 -
8512 - [[patch.unused]]
8513 - name = "ops-status"
8514 - version = "0.1.0"
@@ -51,7 +51,8 @@
51 51 //! | Gone | Comes back when |
52 52 //! |---|---|
53 53 //! | Compose, reply, forward | `3fb2526a`, Max's call on the shape |
54 - //! | Settings > Sync, Settings > Sharing | a route can await a network client |
54 + //! | ~~Settings > Sync~~ | back 2026-08-22: measured per command, its reads are local. See [`settings::sync`] |
55 + //! | Settings > Sharing | its reads are remote, so there is no local state to draw a section from. quasicoherent `82273265` |
55 56 //! | ~~Settings > About~~ | back 2026-08-22: `AppState` holds the version and the platform now, which is what `theme_dirs` did for the theme path. See [`settings::about`] |
56 57 //! | Create Backup | [`data`] finding 2: a described write cannot be long-running |
57 58 //! | The search box | quasicoherent `d52884b0` settled the caret as the renderer's |
@@ -46,12 +46,16 @@
46 46 //! resolved at startup and held, which is the move this header names below
47 47 //! for `theme_dirs`. See [`about`].
48 48 //!
49 - //! Sync and Sharing are still out, and the cause stated here is not theirs
50 - //! either: measured 2026-08-21, every group and sync command awaits a network
51 - //! client (12 `.await` sites in `commands/group.rs` alone) and a route handler
52 - //! is synchronous. `AppHandle` has nothing to do with it. They went out with
53 - //! the swap under goingson `da48cb6d` and come back when a route can hand work
54 - //! to a runtime.
49 + //! - **Sync** left on 2026-08-22, and it is the one that shows why counting is
50 + //! not measuring. It was held out on 13 `.await` sites in `commands/sync.rs`;
51 + //! per command, `sync_status` reads local state and the settings, disconnect
52 + //! and auth-start are synchronous. Only the server-talking actions stay out.
53 + //! See [`sync`].
54 + //!
55 + //! Sharing is the one still out, and its reason is neither `AppHandle` nor a
56 + //! slow write: `group_list`, `group_list_members` and `group_list_invitations`
57 + //! all await, so there is no local state to draw a section from at all. That is
58 + //! the last consumer on quasicoherent `82273265`.
55 59 //!
56 60 //! Import & Export left that sentence entirely. It went half out on 2026-08-16,
57 61 //! as [`data`](super::data), a screen of its own: picking a file to submit is
@@ -111,10 +115,19 @@
111 115
112 116 pub(crate) mod about;
113 117 pub(crate) mod email;
118 + pub(crate) mod sync;
114 119
115 120 #[cfg(test)]
116 121 mod tests;
117 122
123 + /// The region a section's contents answer at.
124 + ///
125 + /// Named once because four things now aim at it: the screen that builds it, and
126 + /// the three sections that answer themselves back after a write. It is also the
127 + /// id `settings-sync.js` wrote into before the swap, which is not a
128 + /// coincidence worth relying on and is worth knowing.
129 + const SECTION_REGION: &str = "settings-content";
130 +
118 131 /// A section of the screen: its address, its heading, and the keys it writes.
119 132 struct Section {
120 133 /// The last path segment, and what the sidebar sends.
@@ -152,7 +165,7 @@
152 165 /// absent rather than disabled, for the reason the task overview left Edit out:
153 166 /// a control that is drawn and does nothing is worse than a control that is not
154 167 /// drawn, and the module header says which and why.
155 - const SECTIONS: [Section; 6] = [
168 + const SECTIONS: [Section; 7] = [
156 169 Section {
157 170 slug: "appearance",
158 171 title: "Appearance",
@@ -192,6 +205,13 @@
192 205 title: "About",
193 206 at: None,
194 207 },
208 + // Added 2026-08-22. Listed as host-bound until somebody measured it per
209 + // command rather than per file; see `sync`.
210 + Section {
211 + slug: "sync",
212 + title: "Sync",
213 + at: None,
214 + },
195 215 ];
196 216
197 217 /// What the app falls back to when a key has never been written.
@@ -506,12 +526,13 @@
506 526 },
507 527 )));
508 528
509 - let mut pane = Slot::new("settings-content", RegionKind::Pane);
529 + let mut pane = Slot::new(SECTION_REGION, RegionKind::Pane);
510 530 pane = pane.extend(match section.slug {
511 531 "notifications" => notifications(&config),
512 532 "planning" => planning(&config),
513 533 "email" => email::pane(state)?,
514 534 "about" => about::pane(state),
535 + "sync" => sync::pane(state),
515 536 _ => appearance(state, &config),
516 537 });
517 538
@@ -538,12 +559,56 @@
538 559 let on = request.payload.get(about::UPDATE_CHECK).unwrap_or_default() != "disabled";
539 560 let said = about::write(state, on)?;
540 561 Ok(Response::fragment(
541 - "settings-content",
542 - Node::Region(Slot::new("settings-content", RegionKind::Pane).extend(about::pane(state))),
562 + SECTION_REGION,
563 + Node::Region(Slot::new(SECTION_REGION, RegionKind::Pane).extend(about::pane(state))),
543 564 )
544 565 .toast(quasi_router::layout::Tone::Success, said))
545 566 }
546 567
568 + /// The Sync section, answered back after a write to it.
569 + ///
570 + /// Its own three routes rather than arms of the config writer, because these
571 + /// are `sync_state` rows read by the sync engine rather than `user_config`
572 + /// keys. See [`sync`].
573 + fn sync_pane(state: &AppState, said: String) -> Result<Response, RouteError> {
574 + Ok(Response::fragment(
575 + SECTION_REGION,
576 + Node::Region(Slot::new(SECTION_REGION, RegionKind::Pane).extend(sync::pane(state))),
577 + )
578 + .toast(quasi_router::layout::Tone::Success, said))
579 + }
580 +
581 + /// Turn automatic syncing on or off.
582 + fn set_sync_auto(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
583 + let on = request.payload.get(sync::AUTO_SYNC).unwrap_or_default() != "disabled";
584 + let said = sync::set_auto(state, on)?;
585 + sync_pane(state, said.to_owned())
586 + }
587 +
588 + /// Set how often the scheduler syncs.
589 + fn set_sync_interval(
590 + state: &AppState,
591 + request: quasi_router::Request,
592 + ) -> Result<Response, RouteError> {
593 + let minutes = request
594 + .payload
595 + .get(sync::INTERVAL)
596 + .unwrap_or_default()
597 + .parse()
598 + .unwrap_or(5);
599 + let said = sync::set_interval(state, minutes)?;
600 + sync_pane(state, said)
601 + }
602 +
603 + /// Forget the stored token and stop syncing.
604 + fn disconnect_sync(
605 + state: &AppState,
606 + _request: quasi_router::Request,
607 + ) -> Result<Response, RouteError> {
608 + let said = sync::disconnect(state)?;
609 + sync_pane(state, said.to_owned())
610 + }
611 +
547 612 /// One section.
548 613 fn section(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
549 614 let slug = request
@@ -639,6 +704,10 @@
639 704 .post("/settings/notifications", set_notifications);
640 705 // Above the section capture, so `email` and `about` are literals rather
641 706 // than section names that happen to match.
642 - let router = email::routes(router).post("/settings/about/update-check", set_update_check);
707 + let router = email::routes(router)
708 + .post("/settings/about/update-check", set_update_check)
709 + .post("/settings/sync/auto", set_sync_auto)
710 + .post("/settings/sync/interval", set_sync_interval)
711 + .post("/settings/sync/disconnect", disconnect_sync);
643 712 router.get("/settings/{section}", section)
644 713 }
@@ -80,12 +80,14 @@
80 80 // it needed.
81 81 assert!(page.contains("About"));
82 82
83 - // Sync and Sharing are still absent, and absent rather than drawn as
84 - // controls that do nothing. Their reason is async I/O rather than the
85 - // `AppHandle` this header used to blame; see the module header.
86 - for missing in ["Sync", "Sharing"] {
87 - assert!(!page.contains(missing), "should not offer: {missing}");
88 - }
83 + // Sync joined them on 2026-08-22, once somebody measured `commands/sync.rs`
84 + // per command rather than per file: its reads are local.
85 + assert!(page.contains("Sync"));
86 +
87 + // Sharing is the one still absent, and absent rather than drawn as a
88 + // control that does nothing. Its reads are remote, so there is no local
89 + // state to draw a section from at all; see quasicoherent `82273265`.
90 + assert!(!page.contains("Sharing"), "should not offer: Sharing");
89 91 }
90 92
91 93 #[tokio::test]
@@ -120,8 +122,8 @@
120 122 async fn a_section_that_is_not_described_is_a_not_found() {
121 123 let state = state().await;
122 124 let error = router()
123 - .handle(&state, Request::get("/settings/sync"))
124 - .expect_err("sync is not described");
125 + .handle(&state, Request::get("/settings/sharing"))
126 + .expect_err("sharing is not described");
125 127 assert_eq!(error.class.http_status(), 404);
126 128 }
127 129
@@ -1,0 +1,283 @@
1 + //! Cloud sync: what it is doing, and the settings that do not need a server.
2 + //!
3 + //! <!-- wiki: quasi-overview -->
4 + //!
5 + //! # The section was called host-bound and it is mostly not
6 + //!
7 + //! [`super`]'s header listed Sync among the sections left out, and the reason
8 + //! recorded against it was async I/O: thirteen `.await` sites in
9 + //! `commands/sync.rs`. The swap took `settings-sync.js` with the rest of the
10 + //! frontend, so the section has been absent since 2026-08-22.
11 + //!
12 + //! Counted per command rather than per file, that number is the busiest control
13 + //! and not what the data needs. It is the same mistake [`super::email`]'s header
14 + //! names having made once, about OAuth:
15 + //!
16 + //! > a section is not undescribable because its loudest feature is, and the test
17 + //! > is what the *data* needs rather than what the busiest control does.
18 + //!
19 + //! `sync_status` — which is nearly the whole of what this section shows — reads
20 + //! `client.config()`, `client.session_info()`, `client.has_master_key()` and two
21 + //! synchronous database queries. `sync_update_settings`, `sync_disconnect` and
22 + //! `sync_start_auth` have no awaits at all.
23 + //!
24 + //! # What is here, and what is not
25 + //!
26 + //! Here: whether sync is configured and signed in, the server, whether
27 + //! encryption is ready, the device, the last sync, the pending-change count,
28 + //! the auto-sync switch, the interval, and Disconnect.
29 + //!
30 + //! Not here, and absent rather than drawn as controls that do nothing: Connect,
31 + //! Sync Now, Set up encryption, Subscribe, and the held-changes drill-in. Every
32 + //! one of those is a conversation with a server, which is the half that stays
33 + //! host-bound — the same arrangement as Email, where the accounts are described
34 + //! and the OAuth handshake is not.
35 + //!
36 + //! That leaves the section honest rather than whole, and the distinction is
37 + //! worth keeping in view: a person can see the state of sync and change how it
38 + //! behaves, and cannot start one from here.
39 + //!
40 + //! # The two facts left out of the readout
41 + //!
42 + //! `sync_status` has exactly two awaits, and both are already written to
43 + //! survive not getting an answer: `has_server_key()` ends in `.ok()`, and
44 + //! `held_counts()` "degrades to zero rather than failing the whole status
45 + //! call". So both are facts the shipped screen is content to be missing.
46 + //!
47 + //! They are missing here too, rather than being fetched. Holding the last known
48 + //! value of each on [`AppState`] and saying how old it is would work, and it is
49 + //! `82273265` option (d) in miniature; two facts do not pay for the machinery.
50 + //! If the held count ever needs to be on screen, that is the shape it takes.
51 + //!
52 + //! # Disconnect is here and Connect is not, which looks odd and is right
53 + //!
54 + //! `sync_disconnect` deletes the stored token and tells the store to stop. Both
55 + //! are local: nothing is asked of the server, and a server that never hears
56 + //! about it is not a failure case, because the token is what this device holds
57 + //! rather than a session the server tracks.
58 + //!
59 + //! Connecting is the other way round entirely — a handshake, a poll, an
60 + //! exchange — so it stays where it was.
61 +
62 + use quasi_router::layout::Tone;
63 + use quasi_router::screen::{Act, Choice, Field, Row};
64 + use quasi_router::{Action, Node, RouteError};
65 +
66 + use crate::state::AppState;
67 + use crate::syncstore::sync_state;
68 +
69 + /// The keys this section writes, which are `sync_state` rows rather than
70 + /// `user_config` ones.
71 + ///
72 + /// Sync's own settings live in the table the sync engine reads, not in the
73 + /// config table the rest of this screen writes. So they cannot go through
74 + /// `POST /settings/config/{key}` and have routes of their own.
75 + pub(super) const AUTO_SYNC: &str = "auto_sync_enabled";
76 + /// How often the scheduler syncs, in minutes.
77 + pub(super) const INTERVAL: &str = "sync_interval_minutes";
78 +
79 + /// What the section shows.
80 + struct State {
81 + configured: bool,
82 + authenticated: bool,
83 + server_url: Option<String>,
84 + encryption_ready: bool,
85 + device_id: Option<String>,
86 + auto_sync_enabled: bool,
87 + interval_minutes: u32,
88 + last_sync_at: Option<String>,
89 + pending_changes: i64,
90 + }
91 +
92 + /// Read it, all of it locally.
93 + ///
94 + /// The same reads `sync_status` makes, minus its two awaits. A missing row is
95 + /// the default rather than an error: this is a status readout, and a database
96 + /// that cannot answer one key should not blank the section.
97 + fn read(state: &AppState) -> State {
98 + let client = state.read_recovering();
99 + let (configured, authenticated, server_url, encryption_ready) =
100 + client.map_or((false, false, None, false), |client| {
101 + (
102 + true,
103 + client.session_info().is_some(),
104 + Some(client.config().server_url.clone()),
105 + client.has_master_key(),
106 + )
107 + });
108 +
109 + let states = sync_state::get_sync_states_batch(
110 + &state.db,
111 + &[
112 + "device_id",
113 + "auto_sync_enabled",
114 + "sync_interval_minutes",
115 + "last_sync_at",
116 + ],
117 + )
118 + .unwrap_or_default();
119 +
120 + State {
121 + configured,
122 + authenticated,
123 + server_url,
124 + encryption_ready,
125 + device_id: states.get("device_id").filter(|s| !s.is_empty()).cloned(),
126 + // Absent means on, which is what `sync_status` says and what the
127 + // scheduler assumes.
128 + auto_sync_enabled: states.get("auto_sync_enabled").is_none_or(|v| v == "1"),
129 + interval_minutes: states
130 + .get("sync_interval_minutes")
131 + .and_then(|v| v.parse().ok())
132 + .unwrap_or(5),
133 + last_sync_at: states.get("last_sync_at").cloned(),
134 + pending_changes: sync_state::count_pending_changes(&state.db).unwrap_or(0),
135 + }
136 + }
137 +
138 + /// The section.
139 + pub(super) fn pane(app: &AppState) -> Vec<Node> {
140 + let state = read(app);
141 +
142 + let mut nodes = vec![Node::section("Cloud Sync")];
143 +
144 + if !state.configured {
145 + // Nothing to say and nothing to set. Said plainly rather than drawn as
146 + // a section full of dashes and disabled switches.
147 + nodes.push(Node::empty(
148 + "Sync is not set up on this device. Setting it up asks a server for an \
149 + account, which this screen cannot do yet.",
150 + ));
151 + return nodes;
152 + }
153 +
154 + nodes.push(Node::list(facts(&state)));
155 + nodes.push(Node::field(
156 + Field {
157 + options: vec![
158 + Choice::new("enabled", "Enabled (default)"),
159 + Choice::new("disabled", "Disabled"),
160 + ],
161 + value: Some(
162 + if state.auto_sync_enabled {
163 + "enabled"
164 + } else {
165 + "disabled"
166 + }
167 + .to_owned(),
168 + ),
169 + ..Field::new(
170 + makeover_layout::FieldKind::Select,
171 + AUTO_SYNC,
172 + "Sync automatically",
173 + )
174 + }
175 + .hint("When off, nothing is sent or fetched until a sync is started by hand.")
176 + .changes(Action::post("/settings/sync/auto")),
177 + ));
178 + nodes.push(Node::field(
179 + Field {
180 + min: Some("1".to_owned()),
181 + max: Some("1440".to_owned()),
182 + value: Some(state.interval_minutes.to_string()),
183 + ..Field::new(
184 + makeover_layout::FieldKind::Number,
185 + INTERVAL,
186 + "Minutes between syncs",
187 + )
188 + }
189 + .changes(Action::post("/settings/sync/interval")),
190 + ));
191 +
192 + if state.authenticated {
193 + nodes.push(Node::Act(
194 + Act::new("Disconnect", Action::post("/settings/sync/disconnect"))
195 + .tone(Tone::Danger)
196 + .confirm(
197 + "Disconnect this device from cloud sync? Your data stays here; \
198 + nothing more will be sent or fetched until you sign in again.",
199 + ),
200 + ));
201 + }
202 +
203 + nodes
204 + }
205 +
206 + /// The readout, as labelled rows.
207 + fn facts(state: &State) -> Vec<Row> {
208 + let mut rows = vec![Row::new("Status").meta(if state.authenticated {
209 + "Signed in".to_owned()
210 + } else {
211 + "Set up, not signed in".to_owned()
212 + })];
213 +
214 + if let Some(url) = &state.server_url {
215 + rows.push(Row::new("Server").meta(url.clone()));
216 + }
217 + rows.push(Row::new("Encryption").meta(if state.encryption_ready {
218 + "Ready".to_owned()
219 + } else {
220 + "Not set up".to_owned()
221 + }));
222 + if let Some(device) = &state.device_id {
223 + rows.push(Row::new("This device").meta(device.clone()));
224 + }
225 + rows.push(
226 + Row::new("Last sync").meta(
227 + state
228 + .last_sync_at
229 + .clone()
230 + .unwrap_or_else(|| "Never".to_owned()),
231 + ),
232 + );
233 + // Said as a count rather than hidden at zero: "nothing waiting" is the
234 + // answer somebody opening this section is looking for.
235 + rows.push(
236 + Row::new("Waiting to send").meta(match state.pending_changes {
237 + 0 => "Nothing".to_owned(),
238 + 1 => "1 change".to_owned(),
239 + n => format!("{n} changes"),
240 + }),
241 + );
242 +
243 + rows
244 + }
245 +
246 + /// Turn automatic syncing on or off.
247 + pub(super) fn set_auto(app: &AppState, on: bool) -> Result<&'static str, RouteError> {
248 + sync_state::set_sync_state(&app.db, AUTO_SYNC, if on { "1" } else { "0" })
249 + .map_err(|error| RouteError::internal(error.to_string()))?;
250 + Ok(if on {
251 + "Syncing automatically."
252 + } else {
253 + "Automatic syncing off."
254 + })
255 + }
256 +
257 + /// Set how often the scheduler syncs.
258 + ///
259 + /// Clamped rather than refused, which is what every other numeric control on
260 + /// this screen does: a silly number should show a sane one.
261 + pub(super) fn set_interval(app: &AppState, minutes: u32) -> Result<String, RouteError> {
262 + let minutes = minutes.clamp(1, 1440);
263 + sync_state::set_sync_state(&app.db, INTERVAL, &minutes.to_string())
264 + .map_err(|error| RouteError::internal(error.to_string()))?;
265 + Ok(format!("Syncing every {minutes} minutes."))
266 + }
267 +
268 + /// Forget the stored token and stop syncing.
269 + ///
270 + /// Local on both halves: the token is this device's, and the store is told to
271 + /// stop in this process. Nothing is asked of the server, which is why this is
272 + /// here and Connect is not.
273 + pub(super) fn disconnect(app: &AppState) -> Result<&'static str, RouteError> {
274 + crate::oauth::credentials::CredentialStore::delete_sync_token()
275 + .map_err(RouteError::internal)?;
276 + if let Some(store) = &app.sync_store {
277 + store.disconnect();
278 + }
279 + Ok("Disconnected. Your data is still here.")
280 + }
281 +
282 + #[cfg(test)]
283 + mod tests;
@@ -1,0 +1,141 @@
1 + //! The Sync section, driven through the router.
2 + //!
3 + //! The section exists because somebody measured `commands/sync.rs` per command
4 + //! rather than per file, so the tests worth reading are the ones that pin what
5 + //! is here and what is not. A later pass that "finishes" this section by adding
6 + //! Sync Now would be undoing a measurement, not filling a gap.
7 +
8 + use std::sync::Arc;
9 +
10 + use quasi_http::Serves as _;
11 + use quasi_router::{Outcome, Params, Request};
12 +
13 + use crate::quasi::router;
14 + use crate::state::AppState;
15 +
16 + async fn state() -> Arc<AppState> {
17 + let (state, _) = crate::test_utils::setup_test_state().await;
18 + state
19 + }
20 +
21 + fn html(state: &AppState, path: &str) -> String {
22 + let response = router()
23 + .handle(state, Request::get(path).carrying(Params::new()))
24 + .expect("the route answers");
25 + match &response.outcome {
26 + Outcome::Screen(screen) => quasi_webview::Webview::new().screen(screen),
27 + Outcome::Fragment { node, .. } => quasi_webview::Webview::new().fragment(node),
28 + other => panic!("expected content, got {other:?}"),
29 + }
30 + }
31 +
32 + #[tokio::test]
33 + async fn sync_is_a_section_of_settings() {
34 + let state = state().await;
35 + let markup = html(&state, "/settings/sync");
36 + assert!(markup.contains("Cloud Sync"), "{markup}");
37 + assert!(markup.contains("/settings/sync"), "the sidebar offers it");
38 + }
39 +
40 + #[tokio::test]
41 + async fn a_device_with_no_sync_says_so_rather_than_drawing_an_empty_form() {
42 + // A test state has no sync client. Drawing dashes and disabled switches
43 + // would be a section pretending to be about something.
44 + let state = state().await;
45 + assert!(
46 + state.read_recovering().is_none(),
47 + "the fixture has no client"
48 + );
49 +
50 + let markup = html(&state, "/settings/sync");
51 + assert!(markup.contains("not set up"), "{markup}");
52 + assert!(!markup.contains("Minutes between syncs"), "{markup}");
53 + assert!(!markup.contains("Disconnect"), "{markup}");
54 + }
55 +
56 + #[tokio::test]
57 + async fn the_settings_are_written_even_with_no_client() {
58 + // The switch and the interval are `sync_state` rows the scheduler reads.
59 + // They are not the client's, so they are writable whether or not sync is
60 + // set up, and this is the half that proves the section is local.
61 + let state = state().await;
62 +
63 + let response = router()
64 + .handle(
65 + &state,
66 + Request::post("/settings/sync/interval")
67 + .sending(Params::new().with(super::INTERVAL, "30")),
68 + )
69 + .expect("the route answers");
70 + assert!(matches!(response.outcome, Outcome::Fragment { .. }));
71 +
72 + let written =
73 + crate::syncstore::sync_state::get_sync_states_batch(&state.db, &[super::INTERVAL])
74 + .expect("read back");
75 + assert_eq!(written.get(super::INTERVAL).map(String::as_str), Some("30"));
76 + }
77 +
78 + #[tokio::test]
79 + async fn a_silly_interval_is_clamped_rather_than_refused() {
80 + // What every other numeric control on this screen does.
81 + let state = state().await;
82 + router()
83 + .handle(
84 + &state,
85 + Request::post("/settings/sync/interval")
86 + .sending(Params::new().with(super::INTERVAL, "999999")),
87 + )
88 + .expect("the route answers");
89 +
90 + let written =
91 + crate::syncstore::sync_state::get_sync_states_batch(&state.db, &[super::INTERVAL])
92 + .expect("read back");
93 + assert_eq!(
94 + written.get(super::INTERVAL).map(String::as_str),
95 + Some("1440")
96 + );
97 + }
98 +
99 + #[tokio::test]
100 + async fn turning_automatic_syncing_off_writes_the_row_the_scheduler_reads() {
101 + let state = state().await;
102 + router()
103 + .handle(
104 + &state,
105 + Request::post("/settings/sync/auto")
106 + .sending(Params::new().with(super::AUTO_SYNC, "disabled")),
107 + )
108 + .expect("the route answers");
109 +
110 + let written =
111 + crate::syncstore::sync_state::get_sync_states_batch(&state.db, &[super::AUTO_SYNC])
112 + .expect("read back");
113 + assert_eq!(written.get(super::AUTO_SYNC).map(String::as_str), Some("0"));
114 +
115 + // Absent means on, so turning it back on has to write the row rather than
116 + // clear it: the scheduler reads `is_none_or(|v| v == "1")`.
117 + router()
118 + .handle(
119 + &state,
120 + Request::post("/settings/sync/auto")
121 + .sending(Params::new().with(super::AUTO_SYNC, "enabled")),
122 + )
123 + .expect("the route answers");
124 + let written =
125 + crate::syncstore::sync_state::get_sync_states_batch(&state.db, &[super::AUTO_SYNC])
126 + .expect("read back");
127 + assert_eq!(written.get(super::AUTO_SYNC).map(String::as_str), Some("1"));
128 + }
129 +
130 + #[tokio::test]
131 + async fn the_controls_that_need_a_server_are_absent() {
132 + // Pinned so they read as a measurement rather than an unfinished section.
133 + // Each of these is a conversation with a server, which a synchronous route
134 + // handler cannot have; see the module header and quasicoherent 82273265.
135 + let state = state().await;
136 + let markup = html(&state, "/settings/sync");
137 +
138 + for absent in ["Sync Now", "Set up encryption", "Subscribe", "Connect"] {
139 + assert!(!markup.contains(absent), "should not offer: {absent}");
140 + }
141 + }