1#![allow(missing_docs)]
36
37use std::path::PathBuf;
38use std::time::{Duration, Instant};
39
40use serde::Serialize;
41
42use crate::cli::drive::format::{write_scalar_jsonl, JsonlSerialize};
43use crate::drive::client::DriveClient;
44use crate::drive::files_api::FilesApi;
45use crate::drive::lease::check::{
46 conclude_native_leased_write, gate_optional_leased_write, FromLeaseRefusal, LeaseGateRefusal,
47 LeasedWrite,
48};
49use crate::drive::sheets::api::{
50 applied_reply_unreadable_line, BatchUpdateOutcome, RetryHint, SheetsApi,
51};
52use crate::drive::sheets::client::SheetsClient;
53use crate::drive::sheets::grid_range::width_warning;
54use crate::drive::sheets::types::{BatchUpdateRequestItem, RandomizeRangeRequest};
55use crate::drive::sheets::{a1, grid_range, target_gate};
56use crate::drive::types::SheetTargetRefusal;
57use crate::drive::write_gate::{self, DecidingRule, DriveOperation, FolderPermissionRule};
58use crate::request_log::{self, DriveMutationOutcome};
59
60const LOG_OPERATION: &str = "sheets-randomize-range";
61
62const GATE_OPERATIONS: &[DriveOperation] =
68 &[DriveOperation::SheetsWrite, DriveOperation::SheetsStructure];
69
70#[derive(Debug, Clone, PartialEq, Eq)]
71pub struct RandomizeRangeOptions {
72 pub spreadsheet_id: String,
73 pub sheet: Option<String>,
74 pub range: Option<String>,
75 pub dry_run: bool,
76 pub lease_token: Option<String>,
77 pub ledger_path: PathBuf,
78}
79
80#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
81#[serde(tag = "status", rename_all = "kebab-case")]
82pub enum RandomizeRangeResult {
83 WouldChange {
84 range: String,
85 width_warning: Option<String>,
86 },
87 Changed {
88 range: String,
89 width_warning: Option<String>,
90 },
91 RefusedInvalidRequest {
92 detail: String,
93 },
94 RefusedNotASpreadsheet {
95 mime_type: String,
96 },
97 RefusedShortcut,
98 RefusedNoVisibleParents,
99 RefusedSheetNotFound {
100 title: String,
101 available: Vec<String>,
102 },
103 Blocked {
104 operation: DriveOperation,
108 decided_by: Option<DecidingRule>,
109 },
110 RefusedNoLease,
111 RefusedLeaseExpired,
112 RefusedLeaseWrongFile,
113 RefusedLeaseStale,
114 AppliedReplyUnreadable {
117 summary: String,
119 detail: String,
121 range: String,
123 width_warning: Option<String>,
125 },
126 Failed {
127 detail: String,
128 },
129}
130
131impl FromLeaseRefusal for RandomizeRangeResult {
132 fn from_no_lease() -> Self {
133 Self::RefusedNoLease
134 }
135 fn from_lease_expired() -> Self {
136 Self::RefusedLeaseExpired
137 }
138 fn from_lease_wrong_file() -> Self {
139 Self::RefusedLeaseWrongFile
140 }
141 fn from_lease_stale() -> Self {
142 Self::RefusedLeaseStale
143 }
144 fn from_lease_failed(detail: String) -> Self {
145 Self::Failed { detail }
146 }
147}
148
149impl RandomizeRangeResult {
150 fn log_status(&self) -> &'static str {
151 match self {
152 Self::WouldChange { .. } => "would-change",
153 Self::Changed { .. } => "changed",
154 Self::RefusedInvalidRequest { .. } => "refused-invalid-request",
155 Self::RefusedNotASpreadsheet { .. } => "refused-not-a-spreadsheet",
156 Self::RefusedShortcut => "refused-shortcut",
157 Self::RefusedNoVisibleParents => "refused-no-visible-parents",
158 Self::RefusedSheetNotFound { .. } => "refused-sheet-not-found",
159 Self::Blocked { .. } => "blocked",
160 Self::RefusedNoLease => "refused-no-lease",
161 Self::RefusedLeaseExpired => "refused-lease-expired",
162 Self::RefusedLeaseWrongFile => "refused-lease-wrong-file",
163 Self::RefusedLeaseStale => "refused-lease-stale",
164 Self::AppliedReplyUnreadable { .. } => "applied-reply-unreadable",
165 Self::Failed { .. } => "failed",
166 }
167 }
168}
169
170#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
171pub struct RandomizeRangeOutcome {
172 pub spreadsheet_id: String,
173 pub file_name: Option<String>,
174 pub resolved_folder_id: Option<String>,
175 pub result: RandomizeRangeResult,
176}
177
178impl JsonlSerialize for RandomizeRangeOutcome {
179 fn write_jsonl(&self, out: &mut dyn std::io::Write) -> anyhow::Result<()> {
180 write_scalar_jsonl(self, out)
181 }
182}
183
184pub async fn randomize_range(
185 drive: &DriveClient,
186 sheets: &SheetsClient,
187 opts: &RandomizeRangeOptions,
188 rules: &[FolderPermissionRule],
189) -> RandomizeRangeOutcome {
190 let started = Instant::now();
191 let outcome = randomize_range_inner(drive, sheets, opts, rules).await;
192 if !opts.dry_run {
193 record_attempt(&outcome, started.elapsed());
194 }
195 outcome
196}
197
198async fn randomize_range_inner(
199 drive: &DriveClient,
200 sheets: &SheetsClient,
201 opts: &RandomizeRangeOptions,
202 rules: &[FolderPermissionRule],
203) -> RandomizeRangeOutcome {
204 let bare = |result| RandomizeRangeOutcome {
205 spreadsheet_id: opts.spreadsheet_id.clone(),
206 file_name: None,
207 resolved_folder_id: None,
208 result,
209 };
210 let composed = match a1::compose(opts.sheet.as_deref(), opts.range.as_deref()) {
211 Ok(range) => range,
212 Err(err) => {
213 return bare(RandomizeRangeResult::RefusedInvalidRequest {
214 detail: err.to_string(),
215 })
216 }
217 };
218
219 let (target, verdict, denied, resolved_folder_id, requires_lease) =
220 match target_gate::resolve_all(drive, &opts.spreadsheet_id, GATE_OPERATIONS, rules).await {
221 target_gate::TargetGateUnionOutcome::MetadataFetchFailed { detail } => {
222 return bare(RandomizeRangeResult::Failed { detail })
223 }
224 target_gate::TargetGateUnionOutcome::Refused { target, refusal } => {
225 let result = match refusal {
226 SheetTargetRefusal::Shortcut => RandomizeRangeResult::RefusedShortcut,
227 SheetTargetRefusal::NotASpreadsheet { mime_type } => {
228 RandomizeRangeResult::RefusedNotASpreadsheet { mime_type }
229 }
230 SheetTargetRefusal::NoVisibleParents => {
231 RandomizeRangeResult::RefusedNoVisibleParents
232 }
233 };
234 return RandomizeRangeOutcome {
235 spreadsheet_id: opts.spreadsheet_id.clone(),
236 file_name: Some(target.name),
237 resolved_folder_id: None,
238 result,
239 };
240 }
241 target_gate::TargetGateUnionOutcome::GateFetchFailed { target, detail } => {
242 return RandomizeRangeOutcome {
243 spreadsheet_id: opts.spreadsheet_id.clone(),
244 file_name: Some(target.name),
245 resolved_folder_id: None,
246 result: RandomizeRangeResult::Failed { detail },
247 }
248 }
249 target_gate::TargetGateUnionOutcome::Gated {
250 target,
251 verdict,
252 denied,
253 resolved_folder_id,
254 requires_lease,
255 } => (target, verdict, denied, resolved_folder_id, requires_lease),
256 };
257 let gated = |result| RandomizeRangeOutcome {
258 spreadsheet_id: opts.spreadsheet_id.clone(),
259 file_name: Some(target.name.clone()),
260 resolved_folder_id: resolved_folder_id.clone(),
261 result,
262 };
263 if verdict == write_gate::Verdict::Deny {
264 let (operation, decided_by) = denied.unwrap_or((GATE_OPERATIONS[0], None));
268 return gated(RandomizeRangeResult::Blocked {
269 operation,
270 decided_by,
271 });
272 }
273 let api = SheetsApi::new(sheets);
274 let workbook = match api.get_spreadsheet(&opts.spreadsheet_id).await {
275 Ok(book) => book,
276 Err(err) => {
277 return gated(RandomizeRangeResult::Failed {
278 detail: format!("{err:#}"),
279 })
280 }
281 };
282 let (_, grid) = match grid_range::resolve_grid_range(
283 &workbook,
284 &composed,
285 |detail| RandomizeRangeResult::RefusedInvalidRequest { detail },
286 |title, available| RandomizeRangeResult::RefusedSheetNotFound { title, available },
287 ) {
288 Ok(value) => value,
289 Err(result) => return gated(result),
290 };
291 if !grid_range::is_bounded(&grid) {
292 return gated(RandomizeRangeResult::RefusedInvalidRequest {
293 detail: format!(
294 "{composed} is open-ended; randomize-range needs a fully bounded range (e.g. A1:D10)"
295 ),
296 });
297 }
298 let start_column = grid.start_column_index.unwrap_or(0);
299 let end_column = grid.end_column_index.unwrap_or(0);
300 let allocated_columns = grid_range::find_sheet_by_id(&workbook, grid.sheet_id)
301 .and_then(|sheet| sheet.properties.as_ref())
302 .and_then(|props| props.grid_properties.as_ref())
303 .and_then(|props| props.column_count);
304 let width_warning = width_warning("randomizing", start_column, end_column, allocated_columns);
305 let request = BatchUpdateRequestItem::RandomizeRange(RandomizeRangeRequest { range: grid });
306 if opts.dry_run {
307 return gated(RandomizeRangeResult::WouldChange {
308 range: composed,
309 width_warning,
310 });
311 }
312 let leased = LeasedWrite {
313 log_prefix: "drive sheets randomize-range",
314 operation: LOG_OPERATION,
315 ledger_path: &opts.ledger_path,
316 file_id: &opts.spreadsheet_id,
317 };
318 let files_api = FilesApi::new(drive);
319 let lease_grant = match gate_optional_leased_write(
320 leased,
321 &files_api,
322 requires_lease,
323 opts.lease_token.as_deref(),
324 )
325 .await
326 {
327 Ok(grant) => grant,
328 Err(err) => return gated(err.into_result()),
329 };
330 let result = match conclude_native_leased_write(
331 leased,
332 &lease_grant,
333 &files_api,
334 api.batch_update(&opts.spreadsheet_id, vec![request]).await,
335 |err| format!("{err:#}"),
336 )
337 .await
338 {
339 Ok(BatchUpdateOutcome::Applied(_)) => RandomizeRangeResult::Changed {
340 range: composed,
341 width_warning,
342 },
343 Ok(BatchUpdateOutcome::AppliedReplyUnreadable { detail }) => {
345 RandomizeRangeResult::AppliedReplyUnreadable {
346 summary: format!("randomized {composed}"),
347 detail,
348 range: composed,
349 width_warning,
350 }
351 }
352 Err(err) => RandomizeRangeResult::Failed {
353 detail: format!("{err:#}"),
354 },
355 };
356 gated(result)
357}
358
359fn record_attempt(outcome: &RandomizeRangeOutcome, duration: Duration) {
360 request_log::record_drive_mutation(mutation_outcome(outcome, duration));
361}
362
363fn mutation_outcome(outcome: &RandomizeRangeOutcome, duration: Duration) -> DriveMutationOutcome {
366 let decided_by = match &outcome.result {
367 RandomizeRangeResult::Blocked { decided_by, .. } => decided_by.as_ref(),
368 _ => None,
369 };
370 let decided_by = write_gate::decided_by_log_fields(decided_by);
371 let fields_changed = match &outcome.result {
372 RandomizeRangeResult::Changed { range, .. } => Some(format!("randomized {range}")),
373 RandomizeRangeResult::AppliedReplyUnreadable { summary, .. } => Some(summary.clone()),
374 _ => None,
375 };
376 let error = match &outcome.result {
377 RandomizeRangeResult::RefusedInvalidRequest { detail }
378 | RandomizeRangeResult::Failed { detail }
379 | RandomizeRangeResult::AppliedReplyUnreadable { detail, .. } => Some(detail.clone()),
380 _ => None,
381 };
382 DriveMutationOutcome {
383 operation: LOG_OPERATION,
384 file_id: outcome.spreadsheet_id.clone(),
385 file_name: outcome.file_name.clone().unwrap_or_default(),
386 status: outcome.result.log_status().to_string(),
387 resolved_folder_id: outcome.resolved_folder_id.clone(),
388 decided_by_folder_id: decided_by.folder_id,
389 decided_by_depth: decided_by.depth,
390 decided_by_file_id: decided_by.file_id,
391 fields_changed,
392 error,
393 duration,
394 ..Default::default()
395 }
396}
397
398const ORDER_CAVEAT: &str =
402 "the resulting order is chosen by the server and cannot be previewed or reported; the previous row order is not preserved";
403
404pub fn describe_lines(outcome: &RandomizeRangeOutcome) -> Vec<String> {
405 let book = outcome.file_name.as_deref().map_or_else(
406 || format!("'{}'", outcome.spreadsheet_id),
407 |name| format!("'{name}'"),
408 );
409 match &outcome.result {
410 RandomizeRangeResult::WouldChange {
411 range,
412 width_warning,
413 } => grid_range::reorder_caveat_lines(
414 "randomizing",
415 format!("Would randomize the row order of {range} in {book}"),
416 width_warning.as_deref(),
417 false,
418 Some(ORDER_CAVEAT),
419 ),
420 RandomizeRangeResult::Changed {
421 range,
422 width_warning,
423 } => grid_range::reorder_caveat_lines(
424 "randomizing",
425 format!("Randomized the row order of {range} in {book}"),
426 width_warning.as_deref(),
427 true,
428 Some(ORDER_CAVEAT),
429 ),
430 RandomizeRangeResult::RefusedInvalidRequest { detail } => {
431 vec![format!("Refused: {detail}")]
432 }
433 RandomizeRangeResult::RefusedNotASpreadsheet { mime_type } => vec![format!(
434 "Refused: {book} is not a Google Sheet (mimeType: {mime_type})"
435 )],
436 RandomizeRangeResult::RefusedShortcut => vec![format!(
437 "Refused: {book} is a shortcut; randomize-range doesn't follow shortcuts"
438 )],
439 RandomizeRangeResult::RefusedNoVisibleParents => {
440 vec![format!("Refused: {book} has no visible parent folder")]
441 }
442 RandomizeRangeResult::RefusedSheetNotFound { title, available } => {
443 vec![grid_range::no_sheet_refusal(&book, title, available)]
444 }
445 RandomizeRangeResult::Blocked {
446 operation,
447 decided_by,
448 } => vec![match decided_by {
449 Some(rule) => format!(
450 "Blocked: randomize-range on {book} refused for {operation} by rule on {} {}{}",
451 rule.kind_label(),
452 rule.id(),
453 rule.depth_suffix()
454 ),
455 None => format!(
456 "Blocked: randomize-range on {book} refused by default policy (no matching rule \
457 for {operation})"
458 ),
459 }],
460 RandomizeRangeResult::RefusedNoLease => LeaseGateRefusal::NoLease
461 .describe_line(&outcome.spreadsheet_id, &book)
462 .into_iter()
463 .collect(),
464 RandomizeRangeResult::RefusedLeaseExpired => LeaseGateRefusal::Expired
465 .describe_line(&outcome.spreadsheet_id, &book)
466 .into_iter()
467 .collect(),
468 RandomizeRangeResult::RefusedLeaseWrongFile => LeaseGateRefusal::WrongFile
469 .describe_line(&outcome.spreadsheet_id, &book)
470 .into_iter()
471 .collect(),
472 RandomizeRangeResult::RefusedLeaseStale => LeaseGateRefusal::Stale
473 .describe_line(&outcome.spreadsheet_id, &book)
474 .into_iter()
475 .collect(),
476 RandomizeRangeResult::AppliedReplyUnreadable {
477 summary,
478 detail,
479 width_warning,
480 ..
481 } => {
482 let mut lines = grid_range::reorder_caveat_lines(
483 "randomizing",
484 applied_reply_unreadable_line(summary, &book, RetryHint::NotIdempotent, detail),
485 None,
486 true,
487 Some(ORDER_CAVEAT),
488 );
489 lines.splice(
492 1..1,
493 width_warning
494 .iter()
495 .map(|warning| format!("Warning: {warning}")),
496 );
497 lines
498 }
499 RandomizeRangeResult::Failed { detail } => vec![format!("Failed: {detail}")],
500 }
501}
502
503#[cfg(test)]
504#[allow(clippy::unwrap_used)]
505mod tests {
506 use super::*;
507 use crate::drive::auth::{DriveCredentials, DriveGrantedScopes};
508 use crate::drive::sheets::client::SHEETS_API_URL;
509 use crate::drive::test_support::seed_lease;
510 use crate::drive::types::GOOGLE_SHEET_MIME_TYPE;
511 use crate::test_support::env::MapEnv;
512 use crate::utils::secret::Secret;
513 use std::collections::HashSet;
514
515 async fn clients(server: &wiremock::MockServer) -> (DriveClient, SheetsClient) {
516 let credentials = DriveCredentials {
517 client_id: "client-1".into(),
518 client_secret: Secret::new("secret-1"),
519 refresh_token: Secret::new("refresh-1"),
520 scope: DriveGrantedScopes::READONLY,
521 };
522 wiremock::Mock::given(wiremock::matchers::method("POST"))
523 .and(wiremock::matchers::path("/token"))
524 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
525 serde_json::json!({"access_token": "test-token", "expires_in": 3600}),
526 ))
527 .mount(server)
528 .await;
529 let mut drive = DriveClient::new(&server.uri(), &credentials).unwrap();
530 crate::drive::client::test_support::replace_session(
531 &mut drive,
532 &credentials,
533 &format!("{}/token", server.uri()),
534 );
535 let env = MapEnv::new().with(SHEETS_API_URL, &server.uri());
536 let sheets = SheetsClient::from_drive_client_with(&env, &drive).unwrap();
537 (drive, sheets)
538 }
539
540 async fn mount_metadata(server: &wiremock::MockServer) {
541 wiremock::Mock::given(wiremock::matchers::method("GET"))
542 .and(wiremock::matchers::path("/drive/v3/files/sheet-1"))
543 .respond_with(
544 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
545 "id": "sheet-1", "name": "Budget", "mimeType": GOOGLE_SHEET_MIME_TYPE,
546 "parents": ["parent-1"], "version": "1"
547 })),
548 )
549 .mount(server)
550 .await;
551 wiremock::Mock::given(wiremock::matchers::method("GET"))
552 .and(wiremock::matchers::path("/drive/v3/files/parent-1"))
553 .respond_with(
554 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
555 "id": "parent-1", "name": "parent-1",
556 "mimeType": "application/vnd.google-apps.folder", "parents": []
557 })),
558 )
559 .mount(server)
560 .await;
561 wiremock::Mock::given(wiremock::matchers::method("GET"))
562 .and(wiremock::matchers::path("/v4/spreadsheets/sheet-1"))
563 .respond_with(
564 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
565 "spreadsheetId": "sheet-1",
566 "sheets": [{"properties": {
567 "sheetId": 0, "title": "Q1",
568 "gridProperties": {"rowCount": 100, "columnCount": 6}
569 }}]
570 })),
571 )
572 .mount(server)
573 .await;
574 }
575
576 fn mount_file(id: &str, mime_type: &str, parents: &[&str]) -> wiremock::Mock {
579 let parents: Vec<&str> = parents.to_vec();
580 wiremock::Mock::given(wiremock::matchers::method("GET"))
581 .and(wiremock::matchers::path(format!("/drive/v3/files/{id}")))
582 .respond_with(
583 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
584 "id": id, "name": id, "mimeType": mime_type, "parents": parents,
585 "version": "1",
586 })),
587 )
588 }
589
590 fn mount_folder(id: &str) -> wiremock::Mock {
591 mount_file(id, "application/vnd.google-apps.folder", &[])
592 }
593
594 fn mount_workbook_no_column_count() -> wiremock::Mock {
599 wiremock::Mock::given(wiremock::matchers::method("GET"))
600 .and(wiremock::matchers::path("/v4/spreadsheets/sheet-1"))
601 .respond_with(
602 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
603 "spreadsheetId": "sheet-1",
604 "sheets": [{"properties": {
605 "sheetId": 0, "title": "Q1", "gridProperties": {"rowCount": 100}
606 }}]
607 })),
608 )
609 }
610
611 fn options(dry_run: bool) -> RandomizeRangeOptions {
612 RandomizeRangeOptions {
613 spreadsheet_id: "sheet-1".into(),
614 sheet: Some("Q1".into()),
615 range: Some("A2:C10".into()),
616 dry_run,
617 lease_token: None,
618 ledger_path: PathBuf::from("/tmp/unused-randomize-range-ledger"),
619 }
620 }
621
622 fn rule() -> FolderPermissionRule {
623 FolderPermissionRule {
624 folder_id: Some("parent-1".into()),
625 file_id: None,
626 recursive: true,
627 allow: GATE_OPERATIONS.iter().copied().collect(),
628 deny: HashSet::default(),
629 require_lease: false,
630 }
631 }
632
633 fn rule_requiring_lease() -> FolderPermissionRule {
634 FolderPermissionRule {
635 require_lease: true,
636 ..rule()
637 }
638 }
639
640 fn rule_missing_sheets_structure() -> FolderPermissionRule {
644 FolderPermissionRule {
645 folder_id: Some("parent-1".into()),
646 file_id: None,
647 recursive: true,
648 allow: std::iter::once(DriveOperation::SheetsWrite).collect(),
649 deny: std::iter::once(DriveOperation::SheetsStructure).collect(),
650 require_lease: false,
651 }
652 }
653
654 #[test]
655 fn preview_never_claims_to_predict_the_resulting_order() {
656 let outcome = RandomizeRangeOutcome {
657 spreadsheet_id: "id".into(),
658 file_name: Some("Book".into()),
659 resolved_folder_id: None,
660 result: RandomizeRangeResult::WouldChange {
661 range: "Q1!A1:B3".into(),
662 width_warning: width_warning("randomizing", 0, 2, Some(5)),
663 },
664 };
665 let lines = describe_lines(&outcome).join("\n");
666 assert!(lines.contains("separate records"));
667 assert!(lines.contains("cannot be previewed"));
668 assert!(lines.contains("references outside"));
669 }
670
671 #[tokio::test]
672 async fn dry_run_uses_metadata_without_reading_values_or_mutating() {
673 let server = wiremock::MockServer::start().await;
674 let (drive, sheets) = clients(&server).await;
675 mount_metadata(&server).await;
676 let outcome = randomize_range(&drive, &sheets, &options(true), &[rule()]).await;
677 let RandomizeRangeResult::WouldChange { width_warning, .. } = outcome.result else {
678 panic!("expected would-change"); };
680 assert!(width_warning
681 .unwrap()
682 .contains("3 of the sheet's 6 allocated columns"));
683 let requests = server.received_requests().await.unwrap();
684 assert_eq!(requests.len(), 5);
689 assert!(requests
690 .iter()
691 .all(|request| { request.method.as_str() == "GET" || request.url.path() == "/token" }));
692 }
693
694 #[tokio::test]
695 async fn real_run_sends_one_randomize_range_request() {
696 let server = wiremock::MockServer::start().await;
697 let (drive, sheets) = clients(&server).await;
698 mount_metadata(&server).await;
699 wiremock::Mock::given(wiremock::matchers::method("POST"))
700 .and(wiremock::matchers::path(
701 "/v4/spreadsheets/sheet-1:batchUpdate",
702 ))
703 .respond_with(
704 wiremock::ResponseTemplate::new(200)
705 .set_body_json(serde_json::json!({"replies": [{}]})),
706 )
707 .mount(&server)
708 .await;
709 let outcome = randomize_range(&drive, &sheets, &options(false), &[rule()]).await;
710 assert!(
711 matches!(outcome.result, RandomizeRangeResult::Changed { .. }),
712 "{outcome:?}"
713 );
714 let requests = server.received_requests().await.unwrap();
715 let batch = requests
716 .iter()
717 .find(|request| request.url.path().ends_with(":batchUpdate"))
718 .unwrap();
719 let body: serde_json::Value = serde_json::from_slice(&batch.body).unwrap();
720 assert_eq!(
721 body["requests"][0]["randomizeRange"]["range"],
722 serde_json::json!({
723 "sheetId": 0, "startRowIndex": 1, "endRowIndex": 10,
724 "startColumnIndex": 0, "endColumnIndex": 3
725 })
726 );
727 }
728
729 #[tokio::test]
732 async fn rejects_a_range_with_conflicting_sheet_prefix_before_any_network_call() {
733 let server = wiremock::MockServer::start().await;
734 let (drive, sheets) = clients(&server).await;
735 let mut opts = options(false);
736 opts.sheet = Some("Other".into());
737 opts.range = Some("Sheet1!A1:B2".into());
738 let outcome = randomize_range(&drive, &sheets, &opts, &[rule()]).await;
739 let RandomizeRangeResult::RefusedInvalidRequest { detail } = &outcome.result else {
740 panic!("expected RefusedInvalidRequest, got {:?}", outcome.result); };
742 assert!(detail.contains("already names a sheet"), "{detail}");
743 assert!(server.received_requests().await.unwrap().is_empty());
744 }
745
746 #[tokio::test]
749 async fn a_metadata_fetch_failure_surfaces_as_failed() {
750 let server = wiremock::MockServer::start().await;
751 let (drive, sheets) = clients(&server).await;
752 wiremock::Mock::given(wiremock::matchers::method("GET"))
753 .and(wiremock::matchers::path("/drive/v3/files/sheet-1"))
754 .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("not found"))
755 .mount(&server)
756 .await;
757 let outcome = randomize_range(&drive, &sheets, &options(false), &[rule()]).await;
758 assert!(matches!(
759 outcome.result,
760 RandomizeRangeResult::Failed { .. }
761 ));
762 }
763
764 #[tokio::test]
765 async fn non_spreadsheet_is_refused_before_any_gate_or_sheets_call() {
766 let server = wiremock::MockServer::start().await;
767 let (drive, sheets) = clients(&server).await;
768 mount_file("sheet-1", "application/pdf", &["parent-1"])
769 .mount(&server)
770 .await;
771 let outcome = randomize_range(&drive, &sheets, &options(false), &[rule()]).await;
774 assert!(matches!(
775 outcome.result,
776 RandomizeRangeResult::RefusedNotASpreadsheet { .. }
777 ));
778 let text = describe_lines(&outcome).join("\n");
779 assert!(text.contains("is not a Google Sheet"), "{text}");
780 }
781
782 #[tokio::test]
783 async fn shortcut_is_refused_with_its_own_message_not_the_generic_one() {
784 let server = wiremock::MockServer::start().await;
785 let (drive, sheets) = clients(&server).await;
786 mount_file(
787 "sheet-1",
788 "application/vnd.google-apps.shortcut",
789 &["parent-1"],
790 )
791 .mount(&server)
792 .await;
793 let outcome = randomize_range(&drive, &sheets, &options(false), &[rule()]).await;
794 assert!(matches!(
795 outcome.result,
796 RandomizeRangeResult::RefusedShortcut
797 ));
798 let text = describe_lines(&outcome).join("\n");
799 assert!(text.contains("is a shortcut"), "{text}");
800 }
801
802 #[tokio::test]
803 async fn a_sheet_with_no_visible_parents_is_refused_distinctly_from_a_blocked_one() {
804 let server = wiremock::MockServer::start().await;
805 let (drive, sheets) = clients(&server).await;
806 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &[])
807 .mount(&server)
808 .await;
809 let outcome = randomize_range(&drive, &sheets, &options(false), &[]).await;
810 assert!(matches!(
811 outcome.result,
812 RandomizeRangeResult::RefusedNoVisibleParents
813 ));
814 }
815
816 #[tokio::test]
817 async fn a_gate_fetch_failure_surfaces_as_failed() {
818 let server = wiremock::MockServer::start().await;
819 let (drive, sheets) = clients(&server).await;
820 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
821 .mount(&server)
822 .await;
823 wiremock::Mock::given(wiremock::matchers::method("GET"))
824 .and(wiremock::matchers::path("/drive/v3/files/parent-1"))
825 .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
826 .mount(&server)
827 .await;
828 let outcome = randomize_range(&drive, &sheets, &options(false), &[rule()]).await;
829 assert!(matches!(
830 outcome.result,
831 RandomizeRangeResult::Failed { .. }
832 ));
833 }
834
835 #[tokio::test]
838 async fn denied_target_is_blocked_and_records_the_attempt() {
839 let server = wiremock::MockServer::start().await;
840 let (drive, sheets) = clients(&server).await;
841 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
842 .mount(&server)
843 .await;
844 mount_folder("parent-1").mount(&server).await;
845 let outcome = randomize_range(&drive, &sheets, &options(false), &[]).await;
847 assert!(
848 matches!(
849 outcome.result,
850 RandomizeRangeResult::Blocked {
851 decided_by: None,
852 ..
853 }
854 ),
855 "{:?}",
856 outcome.result
857 );
858 }
859
860 #[tokio::test]
861 async fn a_rule_allowing_sheets_write_but_denying_sheets_structure_is_still_blocked() {
862 let server = wiremock::MockServer::start().await;
863 let (drive, sheets) = clients(&server).await;
864 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
865 .mount(&server)
866 .await;
867 mount_folder("parent-1").mount(&server).await;
868 let outcome = randomize_range(
869 &drive,
870 &sheets,
871 &options(false),
872 &[rule_missing_sheets_structure()],
873 )
874 .await;
875 let lines = describe_lines(&outcome).join("\n");
876 assert!(lines.contains("sheets-structure"), "{lines}");
877 assert!(lines.contains("rule on folder parent-1"), "{lines}");
878 let RandomizeRangeResult::Blocked { operation, .. } = outcome.result else {
879 panic!("expected Blocked, got {:?}", outcome.result); };
881 assert_eq!(operation, DriveOperation::SheetsStructure);
882 }
883
884 #[tokio::test]
885 async fn a_workbook_fetch_failure_surfaces_as_failed() {
886 let server = wiremock::MockServer::start().await;
887 let (drive, sheets) = clients(&server).await;
888 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
889 .mount(&server)
890 .await;
891 mount_folder("parent-1").mount(&server).await;
892 wiremock::Mock::given(wiremock::matchers::method("GET"))
893 .and(wiremock::matchers::path("/v4/spreadsheets/sheet-1"))
894 .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
895 .mount(&server)
896 .await;
897 let outcome = randomize_range(&drive, &sheets, &options(false), &[rule()]).await;
898 assert!(matches!(
899 outcome.result,
900 RandomizeRangeResult::Failed { .. }
901 ));
902 }
903
904 #[tokio::test]
907 async fn a_range_without_a_sheet_prefix_is_refused_as_invalid() {
908 let server = wiremock::MockServer::start().await;
909 let (drive, sheets) = clients(&server).await;
910 mount_metadata(&server).await;
911 let mut opts = options(false);
912 opts.sheet = None;
913 opts.range = Some("A2:C10".into()); let outcome = randomize_range(&drive, &sheets, &opts, &[rule()]).await;
915 let RandomizeRangeResult::RefusedInvalidRequest { detail } = &outcome.result else {
916 panic!("expected RefusedInvalidRequest, got {:?}", outcome.result); };
918 assert!(detail.contains("does not name a sheet"), "{detail}");
919 assert!(server
920 .received_requests()
921 .await
922 .unwrap()
923 .iter()
924 .all(|request| { request.method.as_str() == "GET" || request.url.path() == "/token" }));
925 }
926
927 #[tokio::test]
928 async fn a_range_naming_an_unknown_sheet_is_refused_with_available_titles() {
929 let server = wiremock::MockServer::start().await;
930 let (drive, sheets) = clients(&server).await;
931 mount_metadata(&server).await;
932 let mut opts = options(false);
933 opts.sheet = None;
934 opts.range = Some("Nope!A2:C10".into());
935 let outcome = randomize_range(&drive, &sheets, &opts, &[rule()]).await;
936 let RandomizeRangeResult::RefusedSheetNotFound { title, available } = &outcome.result
937 else {
938 panic!("expected RefusedSheetNotFound, got {:?}", outcome.result); };
940 assert_eq!(title, "Nope");
941 assert_eq!(available, &["Q1".to_string()]);
942 }
943
944 #[tokio::test]
945 async fn an_open_ended_range_is_refused_as_invalid() {
946 let server = wiremock::MockServer::start().await;
947 let (drive, sheets) = clients(&server).await;
948 mount_metadata(&server).await;
949 let mut opts = options(false);
950 opts.sheet = None;
951 opts.range = Some("Q1!A:A".into());
952 let outcome = randomize_range(&drive, &sheets, &opts, &[rule()]).await;
953 let RandomizeRangeResult::RefusedInvalidRequest { detail } = &outcome.result else {
954 panic!("expected RefusedInvalidRequest, got {:?}", outcome.result); };
956 assert!(
957 detail.starts_with("Q1!A:A is open-ended"),
958 "the range is echoed as given, with no extra quotes: {detail}"
959 );
960 assert!(server
961 .received_requests()
962 .await
963 .unwrap()
964 .iter()
965 .all(|request| { request.method.as_str() == "GET" || request.url.path() == "/token" }));
966 }
967
968 #[tokio::test]
969 async fn a_sheet_with_no_recorded_column_count_gets_the_generic_width_warning() {
970 let server = wiremock::MockServer::start().await;
971 let (drive, sheets) = clients(&server).await;
972 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
973 .mount(&server)
974 .await;
975 mount_folder("parent-1").mount(&server).await;
976 mount_workbook_no_column_count().mount(&server).await;
977 let outcome = randomize_range(&drive, &sheets, &options(true), &[rule()]).await;
978 let RandomizeRangeResult::WouldChange { width_warning, .. } = outcome.result else {
979 panic!("expected would-change"); };
981 assert!(width_warning.unwrap().contains("may be narrower"));
982 }
983
984 #[tokio::test]
985 async fn a_batch_update_error_is_reported_as_failed() {
986 let server = wiremock::MockServer::start().await;
987 let (drive, sheets) = clients(&server).await;
988 mount_metadata(&server).await;
989 wiremock::Mock::given(wiremock::matchers::method("POST"))
990 .and(wiremock::matchers::path(
991 "/v4/spreadsheets/sheet-1:batchUpdate",
992 ))
993 .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
994 .mount(&server)
995 .await;
996 let outcome = randomize_range(&drive, &sheets, &options(false), &[rule()]).await;
997 assert!(matches!(
998 outcome.result,
999 RandomizeRangeResult::Failed { .. }
1000 ));
1001 }
1002
1003 #[tokio::test]
1004 async fn randomize_with_an_unreadable_reply_reports_applied_reply_unreadable() {
1005 let server = wiremock::MockServer::start().await;
1006 let (drive, sheets) = clients(&server).await;
1007 mount_metadata(&server).await;
1008 wiremock::Mock::given(wiremock::matchers::method("POST"))
1010 .and(wiremock::matchers::path(
1011 "/v4/spreadsheets/sheet-1:batchUpdate",
1012 ))
1013 .respond_with(wiremock::ResponseTemplate::new(200).set_body_string("not json"))
1014 .expect(1)
1015 .mount(&server)
1016 .await;
1017 let outcome = randomize_range(&drive, &sheets, &options(false), &[rule()]).await;
1018 assert_eq!(
1019 outcome.result.log_status(),
1020 "applied-reply-unreadable",
1021 "{outcome:?}"
1022 );
1023 let lines = describe_lines(&outcome);
1024 assert!(
1025 lines[0].starts_with("Applied, but the reply could not be read"),
1026 "{lines:?}"
1027 );
1028 }
1029
1030 #[test]
1031 fn describe_lines_renders_applied_reply_unreadable() {
1032 let outcome = RandomizeRangeOutcome {
1033 spreadsheet_id: "sheet-1".into(),
1034 file_name: Some("Budget".into()),
1035 resolved_folder_id: None,
1036 result: RandomizeRangeResult::AppliedReplyUnreadable {
1037 summary: "randomized Q1!A2:C10".into(),
1038 detail: "bad reply".into(),
1039 range: "Q1!A2:C10".into(),
1040 width_warning: Some("narrow".into()),
1041 },
1042 };
1043 assert_eq!(
1046 describe_lines(&outcome),
1047 vec![
1048 "Applied, but the reply could not be read: randomized Q1!A2:C10 in 'Budget' — \
1049 do not retry; check the spreadsheet first (bad reply)"
1050 .to_string(),
1051 "Warning: narrow".to_string(),
1052 format!(" {ORDER_CAVEAT}"),
1053 " references outside the range may now observe values from a different row"
1054 .to_string(),
1055 ]
1056 );
1057 }
1058
1059 #[test]
1060 fn the_unreadable_reply_is_logged_with_its_summary_and_detail() {
1061 let outcome = RandomizeRangeOutcome {
1062 spreadsheet_id: "sheet-1".into(),
1063 file_name: Some("Budget".into()),
1064 resolved_folder_id: None,
1065 result: RandomizeRangeResult::AppliedReplyUnreadable {
1066 summary: "randomized Q1!A2:C10".into(),
1067 detail: "bad reply".into(),
1068 range: "Q1!A2:C10".into(),
1069 width_warning: None,
1070 },
1071 };
1072 let logged = mutation_outcome(&outcome, Duration::ZERO);
1073 assert_eq!(logged.status, "applied-reply-unreadable");
1074 assert_eq!(
1075 logged.fields_changed.as_deref(),
1076 Some("randomized Q1!A2:C10")
1077 );
1078 assert_eq!(logged.error.as_deref(), Some("bad reply"));
1079 }
1080
1081 #[tokio::test]
1082 async fn a_batch_update_error_under_an_active_lease_records_the_failure() {
1083 let server = wiremock::MockServer::start().await;
1087 let (drive, sheets) = clients(&server).await;
1088 mount_metadata(&server).await;
1089 wiremock::Mock::given(wiremock::matchers::method("POST"))
1090 .and(wiremock::matchers::path(
1091 "/v4/spreadsheets/sheet-1:batchUpdate",
1092 ))
1093 .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
1094 .mount(&server)
1095 .await;
1096 let ledger_path = tempfile::tempdir().unwrap().keep().join("ledger.jsonl");
1097 let token = seed_lease(&ledger_path, "sheet-1", "1");
1098 let mut opts = options(false);
1099 opts.lease_token = Some(token);
1100 opts.ledger_path = ledger_path;
1101 let outcome = randomize_range(&drive, &sheets, &opts, &[rule_requiring_lease()]).await;
1102 assert!(matches!(
1103 outcome.result,
1104 RandomizeRangeResult::Failed { .. }
1105 ));
1106 }
1107
1108 #[tokio::test]
1111 async fn refuses_without_a_lease_when_the_rule_requires_one() {
1112 let server = wiremock::MockServer::start().await;
1113 let (drive, sheets) = clients(&server).await;
1114 mount_metadata(&server).await;
1115 let outcome =
1117 randomize_range(&drive, &sheets, &options(false), &[rule_requiring_lease()]).await;
1118 assert_eq!(outcome.result, RandomizeRangeResult::RefusedNoLease);
1119 }
1120
1121 #[tokio::test]
1122 async fn refuses_an_unknown_lease_token() {
1123 let server = wiremock::MockServer::start().await;
1124 let (drive, sheets) = clients(&server).await;
1125 mount_metadata(&server).await;
1126 let mut opts = options(false);
1127 opts.lease_token = Some("bogus-token".into());
1128 opts.ledger_path = tempfile::tempdir().unwrap().keep().join("ledger.jsonl");
1130 let outcome = randomize_range(&drive, &sheets, &opts, &[rule_requiring_lease()]).await;
1131 assert_eq!(outcome.result, RandomizeRangeResult::RefusedLeaseExpired);
1132 }
1133
1134 #[tokio::test]
1135 async fn refuses_a_lease_bound_to_a_different_file() {
1136 let server = wiremock::MockServer::start().await;
1137 let (drive, sheets) = clients(&server).await;
1138 mount_metadata(&server).await;
1139 let ledger_path = tempfile::tempdir().unwrap().keep().join("ledger.jsonl");
1140 let token = seed_lease(&ledger_path, "other-sheet", "1");
1141 let mut opts = options(false);
1142 opts.lease_token = Some(token);
1143 opts.ledger_path = ledger_path;
1144 let outcome = randomize_range(&drive, &sheets, &opts, &[rule_requiring_lease()]).await;
1145 assert_eq!(outcome.result, RandomizeRangeResult::RefusedLeaseWrongFile);
1146 }
1147
1148 #[tokio::test]
1149 async fn refuses_a_stale_lease() {
1150 let server = wiremock::MockServer::start().await;
1151 let (drive, sheets) = clients(&server).await;
1152 mount_metadata(&server).await;
1155 let ledger_path = tempfile::tempdir().unwrap().keep().join("ledger.jsonl");
1156 let token = seed_lease(&ledger_path, "sheet-1", "0");
1157 let mut opts = options(false);
1158 opts.lease_token = Some(token);
1159 opts.ledger_path = ledger_path;
1160 let outcome = randomize_range(&drive, &sheets, &opts, &[rule_requiring_lease()]).await;
1161 assert_eq!(outcome.result, RandomizeRangeResult::RefusedLeaseStale);
1162 }
1163
1164 #[test]
1167 fn lease_refusals_map_to_their_randomize_range_results() {
1168 assert_eq!(
1169 RandomizeRangeResult::from_no_lease(),
1170 RandomizeRangeResult::RefusedNoLease
1171 );
1172 assert_eq!(
1173 RandomizeRangeResult::from_lease_expired(),
1174 RandomizeRangeResult::RefusedLeaseExpired
1175 );
1176 assert_eq!(
1177 RandomizeRangeResult::from_lease_wrong_file(),
1178 RandomizeRangeResult::RefusedLeaseWrongFile
1179 );
1180 assert_eq!(
1181 RandomizeRangeResult::from_lease_stale(),
1182 RandomizeRangeResult::RefusedLeaseStale
1183 );
1184 assert_eq!(
1185 RandomizeRangeResult::from_lease_failed("ledger unavailable".into()),
1186 RandomizeRangeResult::Failed {
1187 detail: "ledger unavailable".into()
1188 }
1189 );
1190 }
1191
1192 #[test]
1193 fn write_jsonl_emits_one_line_of_json() {
1194 let outcome = RandomizeRangeOutcome {
1195 spreadsheet_id: "sheet-1".into(),
1196 file_name: Some("Budget".into()),
1197 resolved_folder_id: Some("parent-1".into()),
1198 result: RandomizeRangeResult::Changed {
1199 range: "Q1!A2:C10".into(),
1200 width_warning: None,
1201 },
1202 };
1203 let mut buf = Vec::new();
1204 outcome.write_jsonl(&mut buf).unwrap();
1205 let text = String::from_utf8(buf).unwrap();
1206 assert_eq!(text.matches('\n').count(), 1);
1207 let parsed: serde_json::Value = serde_json::from_str(text.trim()).unwrap();
1208 assert_eq!(parsed["result"]["status"], "changed");
1209 }
1210
1211 fn every_randomize_range_result() -> Vec<RandomizeRangeResult> {
1217 let all = vec![
1218 RandomizeRangeResult::WouldChange {
1219 range: "Q1!A1:B3".into(),
1220 width_warning: None,
1221 },
1222 RandomizeRangeResult::Changed {
1223 range: "Q1!A1:B3".into(),
1224 width_warning: Some("narrow".to_string()),
1225 },
1226 RandomizeRangeResult::RefusedInvalidRequest {
1227 detail: "bad".into(),
1228 },
1229 RandomizeRangeResult::RefusedNotASpreadsheet {
1230 mime_type: "application/pdf".into(),
1231 },
1232 RandomizeRangeResult::RefusedShortcut,
1233 RandomizeRangeResult::RefusedNoVisibleParents,
1234 RandomizeRangeResult::RefusedSheetNotFound {
1235 title: "Nope".into(),
1236 available: vec!["Q1".into()],
1237 },
1238 RandomizeRangeResult::Blocked {
1239 operation: DriveOperation::SheetsWrite,
1240 decided_by: None,
1241 },
1242 RandomizeRangeResult::Blocked {
1243 operation: DriveOperation::SheetsStructure,
1244 decided_by: Some(DecidingRule::Folder {
1245 folder_id: "folder-1".into(),
1246 depth: 2,
1247 }),
1248 },
1249 RandomizeRangeResult::Blocked {
1250 operation: DriveOperation::SheetsWrite,
1251 decided_by: Some(DecidingRule::File {
1252 file_id: "sheet-1".into(),
1253 }),
1254 },
1255 RandomizeRangeResult::RefusedNoLease,
1256 RandomizeRangeResult::RefusedLeaseExpired,
1257 RandomizeRangeResult::RefusedLeaseWrongFile,
1258 RandomizeRangeResult::RefusedLeaseStale,
1259 RandomizeRangeResult::AppliedReplyUnreadable {
1260 summary: "randomized Q1!A1:B3".into(),
1261 detail: "bad reply".into(),
1262 range: "Q1!A1:B3".into(),
1263 width_warning: Some("narrow".into()),
1264 },
1265 RandomizeRangeResult::Failed {
1266 detail: "boom".into(),
1267 },
1268 ];
1269 for result in &all {
1270 match result {
1271 RandomizeRangeResult::WouldChange { .. }
1272 | RandomizeRangeResult::Changed { .. }
1273 | RandomizeRangeResult::RefusedInvalidRequest { .. }
1274 | RandomizeRangeResult::RefusedNotASpreadsheet { .. }
1275 | RandomizeRangeResult::RefusedShortcut
1276 | RandomizeRangeResult::RefusedNoVisibleParents
1277 | RandomizeRangeResult::RefusedSheetNotFound { .. }
1278 | RandomizeRangeResult::Blocked { .. }
1279 | RandomizeRangeResult::RefusedNoLease
1280 | RandomizeRangeResult::RefusedLeaseExpired
1281 | RandomizeRangeResult::RefusedLeaseWrongFile
1282 | RandomizeRangeResult::RefusedLeaseStale
1283 | RandomizeRangeResult::AppliedReplyUnreadable { .. }
1284 | RandomizeRangeResult::Failed { .. } => (),
1285 }
1286 }
1287 all
1288 }
1289
1290 #[test]
1291 fn every_describe_arm_renders_at_least_one_line_with_no_control_characters() {
1292 for result in every_randomize_range_result() {
1293 let outcome = RandomizeRangeOutcome {
1294 spreadsheet_id: "sheet-1".into(),
1295 file_name: Some("Quarterly Plan".into()),
1296 resolved_folder_id: None,
1297 result,
1298 };
1299 let lines = describe_lines(&outcome);
1300 assert!(
1301 !lines.is_empty(),
1302 "describe_lines emitted no lines for {:?}",
1303 outcome.result
1304 );
1305 for line in &lines {
1306 assert!(
1307 !line.chars().any(char::is_control),
1308 "describe_lines emitted a control character for {:?}: {lines:?}",
1309 outcome.result
1310 );
1311 }
1312 }
1313 }
1314
1315 #[test]
1316 fn describe_lines_falls_back_to_the_spreadsheet_id_with_no_file_name() {
1317 let outcome = RandomizeRangeOutcome {
1318 spreadsheet_id: "sheet-1".into(),
1319 file_name: None,
1320 resolved_folder_id: None,
1321 result: RandomizeRangeResult::RefusedShortcut,
1322 };
1323 let lines = describe_lines(&outcome);
1324 assert_eq!(lines.len(), 1);
1325 assert!(lines[0].contains("'sheet-1'"), "{lines:?}");
1326 }
1327
1328 #[test]
1329 fn blocked_with_no_deciding_rule_names_the_denied_operation() {
1330 let outcome = RandomizeRangeOutcome {
1331 spreadsheet_id: "sheet-1".into(),
1332 file_name: Some("Budget".into()),
1333 resolved_folder_id: None,
1334 result: RandomizeRangeResult::Blocked {
1335 operation: DriveOperation::SheetsStructure,
1336 decided_by: None,
1337 },
1338 };
1339 let lines = describe_lines(&outcome).join("\n");
1340 assert!(lines.contains("sheets-structure"), "{lines}");
1341 }
1342
1343 #[test]
1344 fn log_status_covers_every_variant() {
1345 for result in every_randomize_range_result() {
1346 assert!(!result.log_status().is_empty());
1347 }
1348 assert_eq!(
1349 RandomizeRangeResult::Blocked {
1350 operation: DriveOperation::SheetsWrite,
1351 decided_by: None
1352 }
1353 .log_status(),
1354 "blocked"
1355 );
1356 assert_eq!(
1357 RandomizeRangeResult::Changed {
1358 range: "Q1!A1:B2".into(),
1359 width_warning: None
1360 }
1361 .log_status(),
1362 "changed"
1363 );
1364 assert_eq!(
1365 RandomizeRangeResult::RefusedLeaseStale.log_status(),
1366 "refused-lease-stale"
1367 );
1368 }
1369}