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