1use std::io::Write as _;
8use std::path::{Path, PathBuf};
9use std::time::Duration;
10
11use chrono::{DateTime, Utc};
12
13use crate::actions::auth_state::read_auth_state;
14use crate::cli::{ProjectDescribeArgs, ProjectDumpArgs, ProjectListArgs};
15use crate::client::DspClient;
16use crate::config::{AuthCache, Config, resolve_token};
17use crate::diagnostic::Diagnostic;
18use crate::model::{CreateDumpOutcome, DumpStatus};
19use crate::render::progress::ProgressReporter;
20use crate::render::{DumpDeleteOutcome, DumpEvent, DumpOutcome, MetaContext, ProjectListView, Renderer};
21
22pub fn list(
28 args: &ProjectListArgs,
29 cfg: &Config,
30 client: &dyn DspClient,
31 renderer: &mut dyn Renderer,
32) -> Result<(), Diagnostic> {
33 let env_token = std::env::var("DSP_TOKEN").ok();
34 run_list_impl(args, cfg, client, renderer, env_token, None)
35}
36
37fn run_list_impl(
49 args: &ProjectListArgs,
50 cfg: &Config,
51 client: &dyn DspClient,
52 renderer: &mut dyn Renderer,
53 env_token: Option<String>,
54 cache_path: Option<&Path>,
55) -> Result<(), Diagnostic> {
56 let cache_result = match cache_path {
58 Some(p) => AuthCache::load_from(p),
59 None => AuthCache::load(),
60 };
61 let cache = match cache_result {
62 Ok(c) => c,
63 Err(e) => {
64 crate::util::warn_auth_cache_load_failed(&e, "falling back to anonymous for project list");
65 AuthCache::default()
66 }
67 };
68
69 let resolved = resolve_token(env_token, &cache, &cfg.server);
71 let token = resolved.as_ref().map(|r| r.token.as_str());
72
73 let auth_state = read_auth_state(resolved.as_ref(), &cache, &cfg.server);
75
76 let mut projects = client.list_projects(&cfg.server, token)?;
78
79 let total = projects.len();
81
82 if let Some(ref f) = args.filter {
84 let lower = f.to_lowercase();
85 projects.retain(|p| {
86 p.shortcode.to_lowercase().contains(&lower)
87 || p.shortname.to_lowercase().contains(&lower)
88 || p.longname.as_deref().unwrap_or("").to_lowercase().contains(&lower)
89 });
90 }
91
92 projects.sort_by(|a, b| a.shortcode.cmp(&b.shortcode));
94
95 let view = ProjectListView { items: projects, total, filter: args.filter.clone() };
97 let meta = MetaContext {
98 server_label: cfg.server.clone(),
99 auth_state,
100 filter_warning: None,
101 count_caveat: None,
102 count_cost: None,
103 };
104 renderer.projects(&view, &meta)
105}
106
107pub fn describe(
113 args: &ProjectDescribeArgs,
114 cfg: &Config,
115 client: &dyn DspClient,
116 renderer: &mut dyn Renderer,
117) -> Result<(), Diagnostic> {
118 let env_token = std::env::var("DSP_TOKEN").ok();
119 run_describe_impl(args, cfg, client, renderer, env_token, None)
120}
121
122fn run_describe_impl(
133 args: &ProjectDescribeArgs,
134 cfg: &Config,
135 client: &dyn DspClient,
136 renderer: &mut dyn Renderer,
137 env_token: Option<String>,
138 cache_path: Option<&Path>,
139) -> Result<(), Diagnostic> {
140 let project = args
142 .project
143 .as_deref()
144 .ok_or_else(|| Diagnostic::Usage("--project <shortcode|shortname|IRI> is required".to_string()))?;
145
146 let cache_result = match cache_path {
148 Some(p) => AuthCache::load_from(p),
149 None => AuthCache::load(),
150 };
151 let cache = match cache_result {
152 Ok(c) => c,
153 Err(e) => {
154 crate::util::warn_auth_cache_load_failed(&e, "falling back to anonymous for project describe");
155 AuthCache::default()
156 }
157 };
158
159 let resolved = resolve_token(env_token, &cache, &cfg.server);
161 let token = resolved.as_ref().map(|r| r.token.as_str());
162
163 let auth_state = read_auth_state(resolved.as_ref(), &cache, &cfg.server);
165
166 let detail = client.describe_project(&cfg.server, project, token)?;
168
169 let meta = MetaContext {
171 server_label: cfg.server.clone(),
172 auth_state,
173 filter_warning: None,
174 count_caveat: None,
175 count_cost: None,
176 };
177 renderer.project_describe(&detail, &meta)
178}
179
180pub fn dump(
189 args: &ProjectDumpArgs,
190 cfg: &Config,
191 client: &dyn DspClient,
192 renderer: &mut dyn Renderer,
193 reporter: &mut dyn ProgressReporter,
194) -> Result<(), Diagnostic> {
195 let env_token = std::env::var("DSP_TOKEN").ok();
196 let cwd =
197 std::env::current_dir().map_err(|e| Diagnostic::Io(format!("could not determine current directory: {e}")))?;
198 run_impl(
199 args,
200 cfg,
201 client,
202 renderer,
203 reporter,
204 env_token,
205 &|d| std::thread::sleep(d),
206 Utc::now(),
207 None,
208 &cwd,
209 )
210}
211
212#[derive(Debug, Clone, Copy, PartialEq, Eq)]
220enum DumpMode {
221 Default,
223 Replace,
225 Delete,
227}
228
229#[allow(clippy::too_many_arguments)]
244fn run_impl(
245 args: &ProjectDumpArgs,
246 cfg: &Config,
247 client: &dyn DspClient,
248 renderer: &mut dyn Renderer,
249 reporter: &mut dyn ProgressReporter,
250 env_token: Option<String>,
251 sleeper: &dyn Fn(Duration),
252 now: DateTime<Utc>,
253 cache_path: Option<&Path>,
254 cwd: &Path,
255) -> Result<(), Diagnostic> {
256 let env_token_would_win = env_token.as_deref().map(str::trim).map(|s| !s.is_empty()).unwrap_or(false);
261
262 let cache_result = match cache_path {
263 Some(p) => AuthCache::load_from(p),
264 None => AuthCache::load(),
265 };
266 let cache = match cache_result {
267 Ok(c) => c,
268 Err(e) if env_token_would_win => {
269 crate::util::warn_auth_cache_load_failed(&e, "DSP_TOKEN is set, falling through to env token");
270 AuthCache::default()
271 }
272 Err(e) => return Err(e),
273 };
274
275 let resolved = resolve_token(env_token, &cache, &cfg.server).ok_or_else(|| {
276 Diagnostic::AuthRequired(
277 "dsp vre project dump requires a system-administrator token; \
278run `dsp auth login --server <s>` or set DSP_TOKEN"
279 .to_string(),
280 )
281 })?;
282 let token = resolved.token.clone();
283
284 let project = args
286 .project
287 .as_deref()
288 .ok_or_else(|| Diagnostic::Usage("--project <shortcode|shortname|IRI> is required".to_string()))?;
289
290 let mode = if args.delete {
292 DumpMode::Delete
293 } else if args.replace {
294 DumpMode::Replace
295 } else {
296 DumpMode::Default
297 };
298
299 let explicit_output = if mode != DumpMode::Delete {
303 args.output.clone()
304 } else {
305 None
306 };
307
308 if let Some(ref path) = explicit_output
309 && path.exists()
310 && !args.force
311 {
312 return Err(Diagnostic::Usage(format!(
313 "refusing to overwrite {path}; pass --force",
314 path = path.display()
315 )));
316 }
317
318 let proj = client.resolve_project(&cfg.server, project)?;
320
321 let output_path: Option<PathBuf> = if mode != DumpMode::Delete {
323 let p = match explicit_output {
324 Some(p) => p,
325 None => {
326 let default = default_output_path(cwd, &proj.shortcode, now);
327 if default.exists() && !args.force {
328 return Err(Diagnostic::Usage(format!(
329 "refusing to overwrite {path}; pass --force",
330 path = default.display()
331 )));
332 }
333 default
334 }
335 };
336 Some(p)
337 } else {
338 None
339 };
340
341 let create_outcome = client.create_project_dump(&cfg.server, &proj.iri, args.skip_assets, &token)?;
343
344 let meta = MetaContext {
348 server_label: cfg.server.clone(),
349 auth_state: read_auth_state(Some(&resolved), &cache, &cfg.server),
350 filter_warning: None,
351 count_caveat: None,
352 count_cost: None,
353 };
354
355 match mode {
357 DumpMode::Default => handle_default(
358 create_outcome,
359 args,
360 cfg,
361 client,
362 renderer,
363 reporter,
364 &token,
365 &proj.iri,
366 output_path.ok_or_else(|| Diagnostic::Internal("output_path unexpectedly None in Default mode".into()))?,
367 &meta,
368 sleeper,
369 ),
370 DumpMode::Replace => handle_replace(
371 create_outcome,
372 args,
373 cfg,
374 client,
375 renderer,
376 reporter,
377 &token,
378 &proj.iri,
379 args.skip_assets,
380 output_path.ok_or_else(|| Diagnostic::Internal("output_path unexpectedly None in Replace mode".into()))?,
381 &meta,
382 sleeper,
383 ),
384 DumpMode::Delete => handle_delete(create_outcome, cfg, client, renderer, reporter, &token, &proj.iri, &meta),
385 }
386}
387
388fn recreated_dump_ids(
402 outcome: CreateDumpOutcome,
403) -> Result<(String, Option<chrono::DateTime<chrono::Utc>>), Diagnostic> {
404 match outcome {
405 CreateDumpOutcome::Created(task2) => Ok((task2.id, task2.created_at)),
406 CreateDumpOutcome::Exists { .. } => Err(Diagnostic::Conflict(
407 "the dump was recreated before it could be replaced; try again".into(),
408 )),
409 CreateDumpOutcome::ExistsForOtherProject { project_iri: racer, .. } => Err(Diagnostic::Conflict(format!(
410 "the dump slot was claimed by another project ({racer}) \
411before this one could be created; try again"
412 ))),
413 }
414}
415
416#[allow(clippy::too_many_arguments)]
428fn handle_default(
429 create_outcome: CreateDumpOutcome,
430 args: &ProjectDumpArgs,
431 cfg: &Config,
432 client: &dyn DspClient,
433 renderer: &mut dyn Renderer,
434 reporter: &mut dyn ProgressReporter,
435 token: &str,
436 project_iri: &str,
437 output_path: PathBuf,
438 meta: &MetaContext,
439 sleeper: &dyn Fn(Duration),
440) -> Result<(), Diagnostic> {
441 match create_outcome {
442 CreateDumpOutcome::Created(task) => {
443 reporter.report(&DumpEvent::Triggered { id: task.id.clone() })?;
444 let created_at = task.created_at;
445 let id = task.id;
446 poll_until_done(client, cfg, reporter, token, project_iri, &id, args.timeout, sleeper)?;
447 reporter.report(&DumpEvent::Downloading)?;
448 let bytes = stream_dump_to_path(client, &cfg.server, project_iri, &id, token, &output_path)?;
449 let cleaned_up = run_cleanup(args.cleanup, client, &cfg.server, project_iri, &id, token);
450 reporter.report(&DumpEvent::Done { bytes })?;
451 renderer.project_dump(
452 &DumpOutcome {
453 path: output_path,
454 bytes,
455 cleaned_up,
456 reused: false,
457 created_at,
458 },
459 meta,
460 )
461 }
462 CreateDumpOutcome::ExistsForOtherProject { project_iri: foreign_iri, .. } => {
463 Err(Diagnostic::Conflict(format!(
464 "no dump exists for the requested project; the server holds a single \
465dump and it currently belongs to a different project ({foreign_iri}). Re-run \
466with --replace --discard-other-project to discard that dump and create this \
467project's, or wait for it to be removed."
468 )))
469 }
470 CreateDumpOutcome::Exists { id } => {
471 let status_task = client.get_project_dump_status(&cfg.server, project_iri, &id, token)?;
473 match status_task.status {
474 DumpStatus::Failed => Err(Diagnostic::Conflict(format!(
475 "the existing dump failed: {}; re-run with --replace to discard \
476and create a fresh one, or --delete to remove it",
477 status_task.error_message.unwrap_or_default()
478 ))),
479 DumpStatus::Completed => {
480 reporter.report(&DumpEvent::Adopting { id: id.clone() })?;
481 reporter.report(&DumpEvent::Downloading)?;
482 let bytes = stream_dump_to_path(client, &cfg.server, project_iri, &id, token, &output_path)?;
483 let cleaned_up = run_cleanup(args.cleanup, client, &cfg.server, project_iri, &id, token);
484 reporter.report(&DumpEvent::Done { bytes })?;
485 renderer.project_dump(
486 &DumpOutcome {
487 path: output_path,
488 bytes,
489 cleaned_up,
490 reused: true,
491 created_at: status_task.created_at,
492 },
493 meta,
494 )
495 }
496 DumpStatus::InProgress => {
497 reporter.report(&DumpEvent::Adopting { id: id.clone() })?;
498 let created_at = status_task.created_at;
499 poll_until_done(client, cfg, reporter, token, project_iri, &id, args.timeout, sleeper)?;
500 reporter.report(&DumpEvent::Downloading)?;
501 let bytes = stream_dump_to_path(client, &cfg.server, project_iri, &id, token, &output_path)?;
502 let cleaned_up = run_cleanup(args.cleanup, client, &cfg.server, project_iri, &id, token);
503 reporter.report(&DumpEvent::Done { bytes })?;
504 renderer.project_dump(
505 &DumpOutcome {
506 path: output_path,
507 bytes,
508 cleaned_up,
509 reused: true,
510 created_at,
511 },
512 meta,
513 )
514 }
515 }
516 }
517 }
518}
519
520#[allow(clippy::too_many_arguments)]
530fn handle_replace(
531 create_outcome: CreateDumpOutcome,
532 args: &ProjectDumpArgs,
533 cfg: &Config,
534 client: &dyn DspClient,
535 renderer: &mut dyn Renderer,
536 reporter: &mut dyn ProgressReporter,
537 token: &str,
538 project_iri: &str,
539 skip_assets: bool,
540 output_path: PathBuf,
541 meta: &MetaContext,
542 sleeper: &dyn Fn(Duration),
543) -> Result<(), Diagnostic> {
544 let (id, created_at) = match create_outcome {
545 CreateDumpOutcome::Created(task) => {
546 reporter.report(&DumpEvent::Triggered { id: task.id.clone() })?;
547 let created_at = task.created_at;
548 let id = task.id;
549 poll_until_done(client, cfg, reporter, token, project_iri, &id, args.timeout, sleeper)?;
550 reporter.report(&DumpEvent::Downloading)?;
551 let bytes = stream_dump_to_path(client, &cfg.server, project_iri, &id, token, &output_path)?;
552 let cleaned_up = run_cleanup(args.cleanup, client, &cfg.server, project_iri, &id, token);
553 reporter.report(&DumpEvent::Done { bytes })?;
554 return renderer.project_dump(
555 &DumpOutcome {
556 path: output_path,
557 bytes,
558 cleaned_up,
559 reused: false,
560 created_at,
561 },
562 meta,
563 );
564 }
565 CreateDumpOutcome::ExistsForOtherProject { id: foreign_id, project_iri: foreign_iri } => {
566 if !args.discard_other_project {
567 return Err(Diagnostic::Conflict(format!(
568 "the server's single dump slot is held by a different project \
569({foreign_iri}); re-run with --replace --discard-other-project to discard that \
570project's dump and create this one's"
571 )));
572 }
573 let foreign = client.get_project_dump_status(&cfg.server, &foreign_iri, &foreign_id, token)?;
576 match foreign.status {
577 DumpStatus::InProgress => {
578 return Err(Diagnostic::Conflict(format!(
579 "a dump for a different project ({foreign_iri}) is currently in \
580progress; it cannot be discarded until it finishes — wait and retry"
581 )));
582 }
583 DumpStatus::Completed | DumpStatus::Failed => {
584 reporter.report(&DumpEvent::DiscardingOtherProjectDump {
585 id: foreign_id.clone(),
586 project_iri: foreign_iri.clone(),
587 })?;
588 client.delete_project_dump(&cfg.server, &foreign_iri, &foreign_id, token)?;
589 let create2 = client.create_project_dump(&cfg.server, project_iri, skip_assets, token)?;
591 recreated_dump_ids(create2)?
592 }
593 }
594 }
595 CreateDumpOutcome::Exists { id } => {
596 let status_task = client.get_project_dump_status(&cfg.server, project_iri, &id, token)?;
597 match status_task.status {
598 DumpStatus::InProgress => {
599 return Err(Diagnostic::Conflict(
600 "a dump is already in progress; it cannot be replaced until it finishes".into(),
601 ));
602 }
603 DumpStatus::Completed | DumpStatus::Failed => {
604 reporter.report(&DumpEvent::Deleting { id: id.clone() })?;
606 client.delete_project_dump(&cfg.server, project_iri, &id, token)?;
607 let create2 = client.create_project_dump(&cfg.server, project_iri, skip_assets, token)?;
609 recreated_dump_ids(create2)?
610 }
611 }
612 }
613 };
614
615 reporter.report(&DumpEvent::Triggered { id: id.clone() })?;
616 poll_until_done(client, cfg, reporter, token, project_iri, &id, args.timeout, sleeper)?;
617 reporter.report(&DumpEvent::Downloading)?;
618 let bytes = stream_dump_to_path(client, &cfg.server, project_iri, &id, token, &output_path)?;
619 let cleaned_up = run_cleanup(args.cleanup, client, &cfg.server, project_iri, &id, token);
620 reporter.report(&DumpEvent::Done { bytes })?;
621 renderer.project_dump(
622 &DumpOutcome {
623 path: output_path,
624 bytes,
625 cleaned_up,
626 reused: false,
627 created_at,
628 },
629 meta,
630 )
631}
632
633#[allow(clippy::too_many_arguments)]
642fn handle_delete(
643 create_outcome: CreateDumpOutcome,
644 cfg: &Config,
645 client: &dyn DspClient,
646 renderer: &mut dyn Renderer,
647 reporter: &mut dyn ProgressReporter,
648 token: &str,
649 project_iri: &str,
650 meta: &MetaContext,
651) -> Result<(), Diagnostic> {
652 match create_outcome {
653 CreateDumpOutcome::ExistsForOtherProject { project_iri: foreign_iri, .. } => renderer.project_dump_deleted(
654 &DumpDeleteOutcome {
655 deleted: false,
656 note: Some(format!(
657 "no dump for the requested project to delete; the server's \
658single dump slot is held by a different project ({foreign_iri})"
659 )),
660 },
661 meta,
662 ),
663 CreateDumpOutcome::Exists { id } => {
664 let status_task = client.get_project_dump_status(&cfg.server, project_iri, &id, token)?;
665 match status_task.status {
666 DumpStatus::InProgress => Err(Diagnostic::Conflict(
667 "the dump is in progress and cannot be deleted until it finishes".into(),
668 )),
669 DumpStatus::Completed | DumpStatus::Failed => {
670 reporter.report(&DumpEvent::Deleting { id: id.clone() })?;
671 client.delete_project_dump(&cfg.server, project_iri, &id, token)?;
672 renderer.project_dump_deleted(&DumpDeleteOutcome { deleted: true, note: None }, meta)
673 }
674 }
675 }
676 CreateDumpOutcome::Created(task) => {
677 let note = format!(
681 "no dump existed to delete; a probe created an in-progress dump {} \
682that will complete server-side",
683 task.id
684 );
685 reporter.report(&DumpEvent::ProbeCreated { id: task.id.clone() })?;
686 tracing::warn!(
687 id = %task.id,
688 "delete mode: no existing dump found; probe created in-progress dump \
689 that will complete server-side"
690 );
691 renderer.project_dump_deleted(&DumpDeleteOutcome { deleted: false, note: Some(note) }, meta)
692 }
693 }
694}
695
696#[allow(clippy::too_many_arguments)]
707fn poll_until_done(
708 client: &dyn DspClient,
709 cfg: &Config,
710 reporter: &mut dyn ProgressReporter,
711 token: &str,
712 project_iri: &str,
713 dump_id: &str,
714 timeout_secs: u64,
715 sleeper: &dyn Fn(Duration),
716) -> Result<(), Diagnostic> {
717 const BASE: Duration = Duration::from_secs(1);
718 const CAP: Duration = Duration::from_secs(30);
719 let timeout = Duration::from_secs(timeout_secs);
720 let mut elapsed = Duration::ZERO;
721 let mut delay = BASE;
722
723 loop {
724 let t = client.get_project_dump_status(&cfg.server, project_iri, dump_id, token)?;
725 match t.status {
726 DumpStatus::Completed => return Ok(()),
727 DumpStatus::Failed => {
728 return Err(Diagnostic::ServerError(format!(
729 "server-side dump failed: {}",
730 t.error_message.unwrap_or_default()
731 )));
732 }
733 DumpStatus::InProgress => {
734 reporter.report(&DumpEvent::Polling {
736 elapsed_secs: elapsed.as_secs(),
737 status: DumpStatus::InProgress,
738 })?;
739 if elapsed + delay >= timeout {
740 return Err(Diagnostic::ServerError(format!(
741 "dump did not complete within {timeout_secs}s; \
742the server-side dump may still be running"
743 )));
744 }
745 sleeper(delay);
746 elapsed += delay;
747 delay = (delay * 2).min(CAP);
748 }
749 }
750 }
751}
752
753fn run_cleanup(
758 cleanup: bool,
759 client: &dyn DspClient,
760 server: &str,
761 project_iri: &str,
762 dump_id: &str,
763 token: &str,
764) -> bool {
765 if !cleanup {
766 return false;
767 }
768 match client.delete_project_dump(server, project_iri, dump_id, token) {
769 Ok(()) => true,
770 Err(e) => {
771 tracing::warn!(error = %e, "cleanup failed; dump not deleted from server");
772 false
773 }
774 }
775}
776
777fn stream_dump_to_path(
796 client: &dyn DspClient,
797 server: &str,
798 project_iri: &str,
799 dump_id: &str,
800 token: &str,
801 final_path: &Path,
802) -> Result<u64, Diagnostic> {
803 let file_name = final_path
807 .file_name()
808 .ok_or_else(|| Diagnostic::Usage(format!("invalid --output path: {}", final_path.display())))?;
809
810 let temp_path = {
811 let pid = std::process::id();
812 let name = format!("{}.{pid}.partial", file_name.to_string_lossy());
813 final_path
814 .parent()
815 .ok_or_else(|| {
816 Diagnostic::Io(format!(
817 "cannot determine parent directory of output path {}",
818 final_path.display()
819 ))
820 })?
821 .join(&name)
822 };
823
824 let mut open_opts = std::fs::OpenOptions::new();
828 open_opts.write(true).create_new(true);
829
830 #[cfg(unix)]
831 {
832 use std::os::unix::fs::OpenOptionsExt;
833 open_opts.mode(0o600);
834 }
835
836 let mut file = open_opts
837 .open(&temp_path)
838 .map_err(|e| Diagnostic::Io(format!("failed to create temp file {}: {e}", temp_path.display())))?;
839
840 let result = client.download_project_dump(server, project_iri, dump_id, token, &mut file);
842
843 let bytes = match result {
844 Err(e) => {
845 let _ = std::fs::remove_file(&temp_path);
847 return Err(e);
848 }
849 Ok(n) => n,
850 };
851
852 file.flush().map_err(|e| {
855 let _ = std::fs::remove_file(&temp_path);
856 Diagnostic::Io(format!("failed to flush temp file {}: {e}", temp_path.display()))
857 })?;
858
859 file.sync_all().map_err(|e| {
860 let _ = std::fs::remove_file(&temp_path);
861 Diagnostic::Io(format!("failed to sync temp file {}: {e}", temp_path.display()))
862 })?;
863
864 std::fs::rename(&temp_path, final_path).map_err(|e| {
866 let _ = std::fs::remove_file(&temp_path);
867 Diagnostic::Io(format!(
868 "failed to rename {} to {}: {e}",
869 temp_path.display(),
870 final_path.display()
871 ))
872 })?;
873
874 Ok(bytes)
875}
876
877fn default_output_path(base: &Path, shortcode: &str, now: DateTime<Utc>) -> PathBuf {
884 base.join(format!("{shortcode}-{}.zip", now.format("%Y%m%dT%H%M%SZ")))
885}
886
887#[cfg(test)]
892mod tests {
893 use std::cell::{Cell, RefCell};
894 use std::collections::VecDeque;
895 use std::path::PathBuf;
896 use std::time::Duration;
897
898 use chrono::{TimeZone, Utc};
899 use tempfile::TempDir;
900
901 use super::{default_output_path, run_describe_impl, run_impl, run_list_impl};
902 use crate::cli::{FormatArgs, ProjectDescribeArgs, ProjectDumpArgs, ProjectListArgs};
903 use crate::client::DspClient;
904 use crate::config::auth_cache::ServerEntry;
905 use crate::config::{AuthCache, Config};
906 use crate::diagnostic::Diagnostic;
907 use crate::model::{
908 CreateDumpOutcome, DataModelSummary, DumpStatus, DumpTask, Project, ProjectDescription, ProjectDetail,
909 ProjectRef,
910 };
911 use crate::render::auth::{AuthLoginOutcome, AuthLogoutOutcome, AuthSetTokenOutcome, AuthStatusOutcome};
912 use crate::render::progress::ProgressReporter;
913 use crate::render::{DumpDeleteOutcome, DumpEvent, DumpOutcome, Format, MetaContext, ProjectListView, Renderer};
914
915 #[derive(Debug, Clone, PartialEq)]
925 enum CallRecord {
926 Resolve,
927 Create {
931 project_iri: String,
932 },
933 Poll,
935 Download,
936 Delete(String),
938 Status(String),
940 ListProjects {
942 token: Option<String>,
943 },
944 }
945
946 struct MockDspClient {
954 resolve_result: Option<Result<ProjectRef, Diagnostic>>,
955 resolve_calls: RefCell<u32>,
956
957 create_result: Option<Result<CreateDumpOutcome, Diagnostic>>,
959 create_sequence: RefCell<VecDeque<Result<CreateDumpOutcome, Diagnostic>>>,
962 create_calls: RefCell<u32>,
963 create_skip_assets: Cell<Option<bool>>,
966
967 poll_sequence: RefCell<VecDeque<Result<DumpTask, Diagnostic>>>,
970 poll_calls: RefCell<u32>,
971
972 status_sequence: RefCell<VecDeque<Result<DumpTask, Diagnostic>>>,
975
976 download_bytes: Option<Vec<u8>>,
977 download_error: Option<Diagnostic>,
978 download_calls: RefCell<u32>,
979
980 delete_result: Option<Result<(), Diagnostic>>,
981 delete_calls: RefCell<u32>,
982
983 list_projects_result: Option<Result<Vec<Project>, Diagnostic>>,
985 list_projects_calls: RefCell<u32>,
986 list_projects_token: RefCell<Option<Option<String>>>,
990
991 describe_project_result: Option<Result<ProjectDetail, Diagnostic>>,
993 describe_project_call: RefCell<Option<(String, Option<String>)>>,
996
997 call_log: RefCell<Vec<CallRecord>>,
999 }
1000
1001 impl MockDspClient {
1002 fn new() -> Self {
1003 Self {
1004 resolve_result: None,
1005 resolve_calls: RefCell::new(0),
1006 create_result: None,
1007 create_sequence: RefCell::new(VecDeque::new()),
1008 create_calls: RefCell::new(0),
1009 create_skip_assets: Cell::new(None),
1010 poll_sequence: RefCell::new(VecDeque::new()),
1011 poll_calls: RefCell::new(0),
1012 status_sequence: RefCell::new(VecDeque::new()),
1013 download_bytes: None,
1014 download_error: None,
1015 download_calls: RefCell::new(0),
1016 delete_result: None,
1017 delete_calls: RefCell::new(0),
1018 list_projects_result: None,
1019 list_projects_calls: RefCell::new(0),
1020 list_projects_token: RefCell::new(None),
1021 describe_project_result: None,
1022 describe_project_call: RefCell::new(None),
1023 call_log: RefCell::new(Vec::new()),
1024 }
1025 }
1026
1027 fn with_resolve_project(mut self, result: Result<ProjectRef, Diagnostic>) -> Self {
1028 self.resolve_result = Some(result);
1029 self
1030 }
1031
1032 fn with_create_dump(mut self, result: Result<CreateDumpOutcome, Diagnostic>) -> Self {
1033 self.create_result = Some(result);
1034 self
1035 }
1036
1037 fn with_create_exists(mut self, id: impl Into<String>) -> Self {
1038 self.create_result = Some(Ok(CreateDumpOutcome::Exists { id: id.into() }));
1039 self
1040 }
1041
1042 fn with_create_exists_other_project(mut self, id: impl Into<String>, project_iri: impl Into<String>) -> Self {
1043 self.create_result = Some(Ok(CreateDumpOutcome::ExistsForOtherProject {
1044 id: id.into(),
1045 project_iri: project_iri.into(),
1046 }));
1047 self
1048 }
1049
1050 fn with_create_sequence(
1053 mut self,
1054 seq: impl IntoIterator<Item = Result<CreateDumpOutcome, Diagnostic>>,
1055 ) -> Self {
1056 self.create_sequence = RefCell::new(seq.into_iter().collect());
1057 self
1058 }
1059
1060 fn with_poll_sequence(mut self, seq: impl IntoIterator<Item = Result<DumpTask, Diagnostic>>) -> Self {
1061 self.poll_sequence = RefCell::new(seq.into_iter().collect());
1062 self
1063 }
1064
1065 fn with_status_sequence(mut self, seq: impl IntoIterator<Item = Result<DumpTask, Diagnostic>>) -> Self {
1068 self.status_sequence = RefCell::new(seq.into_iter().collect());
1069 self
1070 }
1071
1072 fn with_download_bytes(mut self, bytes: Vec<u8>) -> Self {
1073 self.download_bytes = Some(bytes);
1074 self
1075 }
1076
1077 fn with_download_error(mut self, err: Diagnostic) -> Self {
1078 self.download_error = Some(err);
1079 self
1080 }
1081
1082 fn with_delete_result(mut self, result: Result<(), Diagnostic>) -> Self {
1083 self.delete_result = Some(result);
1084 self
1085 }
1086
1087 fn with_list_projects_result(mut self, result: Result<Vec<Project>, Diagnostic>) -> Self {
1088 self.list_projects_result = Some(result);
1089 self
1090 }
1091
1092 fn with_describe_project_result(mut self, result: Result<ProjectDetail, Diagnostic>) -> Self {
1093 self.describe_project_result = Some(result);
1094 self
1095 }
1096
1097 fn call_log(&self) -> Vec<CallRecord> {
1098 self.call_log.borrow().clone()
1099 }
1100
1101 fn list_projects_token(&self) -> Option<String> {
1104 self.list_projects_token
1105 .borrow()
1106 .as_ref()
1107 .expect("list_projects was not called")
1108 .clone()
1109 }
1110
1111 fn describe_project_call(&self) -> (String, Option<String>) {
1114 self.describe_project_call
1115 .borrow()
1116 .clone()
1117 .expect("describe_project was not called")
1118 }
1119
1120 fn describe_project_was_called(&self) -> bool {
1122 self.describe_project_call.borrow().is_some()
1123 }
1124 }
1125
1126 impl DspClient for MockDspClient {
1127 fn login(
1128 &self,
1129 _server: &str,
1130 _user: &str,
1131 _password: &str,
1132 ) -> Result<crate::model::LoginResponse, Diagnostic> {
1133 unimplemented!("login not used in dump tests")
1134 }
1135
1136 fn resolve_project(&self, _server: &str, _project: &str) -> Result<ProjectRef, Diagnostic> {
1137 *self.resolve_calls.borrow_mut() += 1;
1138 self.call_log.borrow_mut().push(CallRecord::Resolve);
1139 self.resolve_result
1140 .clone()
1141 .expect("resolve_result must be set when resolve_project is called")
1142 }
1143
1144 fn create_project_dump(
1145 &self,
1146 _server: &str,
1147 project_iri: &str,
1148 skip_assets: bool,
1149 _token: &str,
1150 ) -> Result<CreateDumpOutcome, Diagnostic> {
1151 *self.create_calls.borrow_mut() += 1;
1152 self.call_log
1153 .borrow_mut()
1154 .push(CallRecord::Create { project_iri: project_iri.to_string() });
1155 self.create_skip_assets.set(Some(skip_assets));
1156 if *self.create_calls.borrow() > 1
1158 && let Some(result) = self.create_sequence.borrow_mut().pop_front()
1159 {
1160 return result;
1161 }
1162 self.create_result
1163 .clone()
1164 .expect("create_result must be set when create_project_dump is called")
1165 }
1166
1167 fn get_project_dump_status(
1168 &self,
1169 _server: &str,
1170 project_iri: &str,
1171 _dump_id: &str,
1172 _token: &str,
1173 ) -> Result<DumpTask, Diagnostic> {
1174 let from_status = self.status_sequence.borrow_mut().pop_front();
1179 if let Some(result) = from_status {
1180 self.call_log.borrow_mut().push(CallRecord::Status(project_iri.to_string()));
1181 return result;
1182 }
1183 *self.poll_calls.borrow_mut() += 1;
1184 self.call_log.borrow_mut().push(CallRecord::Poll);
1185 self.poll_sequence.borrow_mut().pop_front().expect(
1186 "poll_sequence exhausted — test bug: provide enough entries or let the logical clock fire first",
1187 )
1188 }
1189
1190 fn download_project_dump(
1191 &self,
1192 _server: &str,
1193 _project_iri: &str,
1194 _dump_id: &str,
1195 _token: &str,
1196 dest: &mut dyn std::io::Write,
1197 ) -> Result<u64, Diagnostic> {
1198 *self.download_calls.borrow_mut() += 1;
1199 self.call_log.borrow_mut().push(CallRecord::Download);
1200 if let Some(ref e) = self.download_error {
1201 return Err(e.clone());
1202 }
1203 let bytes = self.download_bytes.as_deref().unwrap_or(&[]);
1204 dest.write_all(bytes)
1205 .map_err(|e| Diagnostic::Internal(format!("mock write error: {e}")))?;
1206 Ok(bytes.len() as u64)
1207 }
1208
1209 fn delete_project_dump(
1210 &self,
1211 _server: &str,
1212 project_iri: &str,
1213 _dump_id: &str,
1214 _token: &str,
1215 ) -> Result<(), Diagnostic> {
1216 *self.delete_calls.borrow_mut() += 1;
1217 self.call_log.borrow_mut().push(CallRecord::Delete(project_iri.to_string()));
1218 self.delete_result
1219 .clone()
1220 .expect("delete_result must be set when delete_project_dump is called")
1221 }
1222
1223 fn list_projects(&self, _server: &str, token: Option<&str>) -> Result<Vec<crate::model::Project>, Diagnostic> {
1224 *self.list_projects_calls.borrow_mut() += 1;
1225 *self.list_projects_token.borrow_mut() = Some(token.map(str::to_owned));
1226 self.call_log
1227 .borrow_mut()
1228 .push(CallRecord::ListProjects { token: token.map(str::to_owned) });
1229 match &self.list_projects_result {
1230 Some(r) => r.clone(),
1231 None => Err(Diagnostic::NotImplemented(
1232 "list_projects not configured in MockDspClient".into(),
1233 )),
1234 }
1235 }
1236
1237 fn describe_project(
1238 &self,
1239 _server: &str,
1240 project: &str,
1241 token: Option<&str>,
1242 ) -> Result<crate::model::ProjectDetail, Diagnostic> {
1243 *self.describe_project_call.borrow_mut() = Some((project.to_owned(), token.map(str::to_owned)));
1244 match &self.describe_project_result {
1245 Some(r) => r.clone(),
1246 None => Err(Diagnostic::NotImplemented(
1247 "describe_project not configured in MockDspClient".into(),
1248 )),
1249 }
1250 }
1251
1252 fn list_data_models(
1253 &self,
1254 _server: &str,
1255 _project_iri: &str,
1256 _token: Option<&str>,
1257 ) -> Result<Vec<crate::model::DataModel>, Diagnostic> {
1258 unimplemented!("list_data_models not used by project tests")
1259 }
1260
1261 fn describe_data_model(
1262 &self,
1263 _server: &str,
1264 _data_model_iri: &str,
1265 _token: Option<&str>,
1266 ) -> Result<crate::model::DataModelDetail, Diagnostic> {
1267 unimplemented!("describe_data_model not used in project tests")
1268 }
1269
1270 fn describe_resource_type(
1271 &self,
1272 _server: &str,
1273 _data_model_iri: &str,
1274 _resource_type: &str,
1275 _token: Option<&str>,
1276 ) -> Result<crate::model::ResourceTypeDetail, Diagnostic> {
1277 unimplemented!("describe_resource_type not used in project tests")
1278 }
1279
1280 fn data_model_structure(
1281 &self,
1282 _server: &str,
1283 _data_model_iri: &str,
1284 _token: Option<&str>,
1285 ) -> Result<crate::model::DataModelStructure, Diagnostic> {
1286 unimplemented!("data_model_structure not used in project tests")
1287 }
1288
1289 fn list_resources(
1290 &self,
1291 _server: &str,
1292 _project_iri: &str,
1293 _resource_type_iri: &str,
1294 _order_by: Option<&str>,
1295 _page: u32,
1296 _token: Option<&str>,
1297 ) -> Result<crate::model::ResourcePage, Diagnostic> {
1298 unimplemented!("list_resources not used in project tests")
1299 }
1300
1301 fn describe_resource(
1302 &self,
1303 _server: &str,
1304 _resource_iri: &str,
1305 _token: Option<&str>,
1306 _with_values: bool,
1307 ) -> Result<crate::model::ResourceDetail, Diagnostic> {
1308 unimplemented!("describe_resource not used in project tests")
1309 }
1310
1311 fn verify_token(&self, _server: &str, _token: &str) -> Result<(), Diagnostic> {
1312 unimplemented!("verify_token not used by dump tests")
1313 }
1314
1315 fn resource_counts(
1316 &self,
1317 _server: &str,
1318 _project_iri: &str,
1319 _token: Option<&str>,
1320 ) -> Result<std::collections::HashMap<String, u64>, Diagnostic> {
1321 Ok(std::collections::HashMap::new())
1322 }
1323
1324 fn list_vocabularies(
1325 &self,
1326 _server: &str,
1327 _project_iri: &str,
1328 _token: Option<&str>,
1329 ) -> Result<Vec<crate::model::Vocabulary>, Diagnostic> {
1330 unimplemented!("not exercised by this file's tests")
1331 }
1332
1333 fn describe_vocabulary(
1334 &self,
1335 _server: &str,
1336 _iri: &str,
1337 _token: Option<&str>,
1338 ) -> Result<crate::model::VocabularyTree, Diagnostic> {
1339 unimplemented!("not exercised by this file's tests")
1340 }
1341
1342 fn sparql_query(
1343 &self,
1344 _server: &str,
1345 _token: &str,
1346 _query: &str,
1347 _accept: &str,
1348 _timeout_secs: u64,
1349 ) -> Result<crate::client::sparql::SparqlResponse, Diagnostic> {
1350 Err(Diagnostic::Internal("not used in this test".into()))
1351 }
1352 }
1353
1354 struct RecordingRenderer {
1357 dump_outcome: Option<DumpOutcome>,
1358 dump_meta: Option<MetaContext>,
1359 dump_deleted_outcome: Option<DumpDeleteOutcome>,
1360 dump_deleted_meta: Option<MetaContext>,
1361 projects_view: Option<(Vec<Project>, usize, Option<String>)>,
1363 projects_meta: Option<MetaContext>,
1364 describe_detail: Option<ProjectDetail>,
1366 describe_meta: Option<MetaContext>,
1367 }
1368
1369 impl RecordingRenderer {
1370 fn new() -> Self {
1371 Self {
1372 dump_outcome: None,
1373 dump_meta: None,
1374 dump_deleted_outcome: None,
1375 dump_deleted_meta: None,
1376 projects_view: None,
1377 projects_meta: None,
1378 describe_detail: None,
1379 describe_meta: None,
1380 }
1381 }
1382 }
1383
1384 impl Renderer for RecordingRenderer {
1385 fn diagnostic(&mut self, _diag: &Diagnostic, _meta: &MetaContext) -> Result<(), Diagnostic> {
1386 Ok(())
1387 }
1388
1389 fn auth_login(&mut self, _outcome: &AuthLoginOutcome, _meta: &MetaContext) -> Result<(), Diagnostic> {
1390 Ok(())
1391 }
1392
1393 fn auth_status(&mut self, _outcome: &AuthStatusOutcome, _meta: &MetaContext) -> Result<(), Diagnostic> {
1394 Ok(())
1395 }
1396
1397 fn auth_logout(&mut self, _outcome: &AuthLogoutOutcome, _meta: &MetaContext) -> Result<(), Diagnostic> {
1398 Ok(())
1399 }
1400
1401 fn auth_set_token(&mut self, _outcome: &AuthSetTokenOutcome, _meta: &MetaContext) -> Result<(), Diagnostic> {
1402 Ok(())
1403 }
1404
1405 fn project_dump(&mut self, outcome: &DumpOutcome, meta: &MetaContext) -> Result<(), Diagnostic> {
1406 self.dump_outcome = Some(DumpOutcome {
1407 path: outcome.path.clone(),
1408 bytes: outcome.bytes,
1409 cleaned_up: outcome.cleaned_up,
1410 reused: outcome.reused,
1411 created_at: outcome.created_at,
1412 });
1413 self.dump_meta = Some(meta.clone());
1414 Ok(())
1415 }
1416
1417 fn project_dump_deleted(&mut self, outcome: &DumpDeleteOutcome, meta: &MetaContext) -> Result<(), Diagnostic> {
1418 self.dump_deleted_outcome =
1419 Some(DumpDeleteOutcome { deleted: outcome.deleted, note: outcome.note.clone() });
1420 self.dump_deleted_meta = Some(meta.clone());
1421 Ok(())
1422 }
1423
1424 fn projects(&mut self, view: &ProjectListView, meta: &MetaContext) -> Result<(), Diagnostic> {
1425 self.projects_view = Some((view.items.clone(), view.total, view.filter.clone()));
1426 self.projects_meta = Some(meta.clone());
1427 Ok(())
1428 }
1429
1430 fn project_describe(&mut self, project: &ProjectDetail, meta: &MetaContext) -> Result<(), Diagnostic> {
1431 self.describe_detail = Some(project.clone());
1432 self.describe_meta = Some(meta.clone());
1433 Ok(())
1434 }
1435
1436 fn data_models(
1437 &mut self,
1438 _view: &crate::render::DataModelListView,
1439 _meta: &MetaContext,
1440 ) -> Result<(), Diagnostic> {
1441 Ok(())
1442 }
1443
1444 fn data_model_describe(
1445 &mut self,
1446 _detail: &crate::model::DataModelDetail,
1447 _meta: &MetaContext,
1448 ) -> Result<(), Diagnostic> {
1449 Ok(())
1450 }
1451
1452 fn resource_types(
1453 &mut self,
1454 _view: &crate::render::ResourceTypeListView,
1455 _meta: &MetaContext,
1456 ) -> Result<(), Diagnostic> {
1457 Ok(())
1458 }
1459
1460 fn resource_type_describe(
1461 &mut self,
1462 _detail: &crate::model::ResourceTypeDetail,
1463 _meta: &MetaContext,
1464 ) -> Result<(), Diagnostic> {
1465 unimplemented!("resource_type_describe not used in project action tests")
1466 }
1467
1468 fn data_model_structure(
1469 &mut self,
1470 _structure: &crate::model::DataModelStructure,
1471 _meta: &MetaContext,
1472 ) -> Result<(), Diagnostic> {
1473 unimplemented!("data_model_structure not used in project action tests")
1474 }
1475
1476 fn resources(
1477 &mut self,
1478 _view: &crate::render::ResourceListView,
1479 _meta: &MetaContext,
1480 ) -> Result<(), Diagnostic> {
1481 Ok(())
1482 }
1483
1484 fn resource_describe(
1485 &mut self,
1486 _detail: &crate::model::ResourceDetail,
1487 _meta: &MetaContext,
1488 ) -> Result<(), Diagnostic> {
1489 Ok(())
1490 }
1491
1492 fn vocabularies(
1493 &mut self,
1494 _view: &crate::render::VocabularyListView,
1495 _meta: &MetaContext,
1496 ) -> Result<(), Diagnostic> {
1497 unimplemented!("not exercised by this file's tests")
1498 }
1499
1500 fn vocabulary_describe(
1501 &mut self,
1502 _detail: &crate::model::VocabularyDetail,
1503 _meta: &MetaContext,
1504 ) -> Result<(), Diagnostic> {
1505 unimplemented!("not exercised by this file's tests")
1506 }
1507 }
1508
1509 struct RecordingProgressReporter {
1512 events: Vec<EventRecord>,
1513 }
1514
1515 #[derive(Debug, PartialEq)]
1516 enum EventRecord {
1517 Triggered(String),
1518 Polling(u64),
1519 Downloading,
1520 Done(u64),
1521 Adopting(String),
1522 Deleting(String),
1523 ProbeCreated(String),
1524 DiscardingOtherProjectDump { id: String, project_iri: String },
1525 }
1526
1527 impl RecordingProgressReporter {
1528 fn new() -> Self {
1529 Self { events: Vec::new() }
1530 }
1531 }
1532
1533 impl ProgressReporter for RecordingProgressReporter {
1534 fn report(&mut self, event: &DumpEvent) -> Result<(), Diagnostic> {
1535 match event {
1536 DumpEvent::Triggered { id } => self.events.push(EventRecord::Triggered(id.clone())),
1537 DumpEvent::Polling { elapsed_secs, .. } => self.events.push(EventRecord::Polling(*elapsed_secs)),
1538 DumpEvent::Downloading => self.events.push(EventRecord::Downloading),
1539 DumpEvent::Done { bytes } => self.events.push(EventRecord::Done(*bytes)),
1540 DumpEvent::Adopting { id } => self.events.push(EventRecord::Adopting(id.clone())),
1541 DumpEvent::Deleting { id } => self.events.push(EventRecord::Deleting(id.clone())),
1542 DumpEvent::ProbeCreated { id } => self.events.push(EventRecord::ProbeCreated(id.clone())),
1543 DumpEvent::DiscardingOtherProjectDump { id, project_iri } => self
1544 .events
1545 .push(EventRecord::DiscardingOtherProjectDump { id: id.clone(), project_iri: project_iri.clone() }),
1546 }
1547 Ok(())
1548 }
1549 }
1550
1551 fn fixed_now() -> chrono::DateTime<Utc> {
1554 Utc.with_ymd_and_hms(2026, 5, 29, 12, 0, 0).unwrap()
1555 }
1556
1557 fn make_project_ref() -> ProjectRef {
1558 ProjectRef {
1559 iri: "http://rdfh.ch/projects/0001".to_string(),
1560 shortcode: "0001".to_string(),
1561 shortname: "anything".to_string(),
1562 }
1563 }
1564
1565 fn make_dump_task(status: DumpStatus) -> DumpTask {
1566 DumpTask {
1567 id: "dump-id-42".to_string(),
1568 status,
1569 error_message: None,
1570 created_at: None,
1571 }
1572 }
1573
1574 fn created_task(status: DumpStatus) -> CreateDumpOutcome {
1575 CreateDumpOutcome::Created(make_dump_task(status))
1576 }
1577
1578 fn make_args(dir: &TempDir) -> (ProjectDumpArgs, Config) {
1579 let args = ProjectDumpArgs {
1580 server: Some("https://api.test.dasch.swiss".to_string()),
1581 project: Some("0001".to_string()),
1582 skip_assets: false,
1583 output: Some(dir.path().join("out.zip")),
1584 force: false,
1585 cleanup: false,
1586 timeout: 3600,
1587 replace: false,
1588 delete: false,
1589 discard_other_project: false,
1590 format: FormatArgs {
1591 format: Format::Prose,
1592 json: false,
1593 lines: false,
1594 columns: None,
1595 no_header: false,
1596 header_only: false,
1597 },
1598 };
1599 let cfg = Config { server: "https://api.test.dasch.swiss".to_string() };
1600 (args, cfg)
1601 }
1602
1603 fn no_op_sleeper() -> impl Fn(Duration) {
1604 |_| {}
1605 }
1606
1607 #[test]
1610 fn happy_path_resolve_trigger_poll_download() {
1611 let dir = TempDir::new().unwrap();
1612 let (args, cfg) = make_args(&dir);
1613
1614 let client = MockDspClient::new()
1615 .with_resolve_project(Ok(make_project_ref()))
1616 .with_create_dump(Ok(created_task(DumpStatus::InProgress)))
1617 .with_poll_sequence([
1618 Ok(make_dump_task(DumpStatus::InProgress)),
1619 Ok(make_dump_task(DumpStatus::Completed)),
1620 ])
1621 .with_download_bytes(b"PK fake zip content".to_vec());
1622
1623 let mut renderer = RecordingRenderer::new();
1624 let mut reporter = RecordingProgressReporter::new();
1625
1626 run_impl(
1627 &args,
1628 &cfg,
1629 &client,
1630 &mut renderer,
1631 &mut reporter,
1632 Some("env-token-abc".to_string()),
1633 &no_op_sleeper(),
1634 fixed_now(),
1635 None,
1636 dir.path(),
1637 )
1638 .unwrap();
1639
1640 let outcome = renderer.dump_outcome.unwrap();
1641 assert_eq!(outcome.bytes, 19); assert!(!outcome.cleaned_up);
1643
1644 assert!(outcome.path.exists(), "output file must exist after happy-path download");
1646 assert!(
1647 outcome.path.metadata().unwrap().len() > 0,
1648 "output file must be non-empty after happy-path download"
1649 );
1650
1651 assert_eq!(reporter.events[0], EventRecord::Triggered("dump-id-42".to_string()));
1653 assert_eq!(reporter.events[1], EventRecord::Polling(0));
1654 assert_eq!(reporter.events[2], EventRecord::Downloading);
1655 assert_eq!(reporter.events[3], EventRecord::Done(19));
1656 assert_eq!(reporter.events.len(), 4);
1657 }
1658
1659 #[test]
1660 fn default_filename_uses_shortcode_and_fixed_timestamp() {
1661 let dir = TempDir::new().unwrap();
1662 let cache_path = dir.path().join("auth.toml");
1663 let args = ProjectDumpArgs {
1667 server: Some("https://api.test.dasch.swiss".to_string()),
1668 project: Some("0001".to_string()),
1669 skip_assets: false,
1670 output: None,
1671 force: true, cleanup: false,
1673 timeout: 3600,
1674 replace: false,
1675 delete: false,
1676 discard_other_project: false,
1677 format: FormatArgs {
1678 format: Format::Prose,
1679 json: false,
1680 lines: false,
1681 columns: None,
1682 no_header: false,
1683 header_only: false,
1684 },
1685 };
1686 let cfg = Config { server: "https://api.test.dasch.swiss".to_string() };
1687
1688 let client = MockDspClient::new()
1689 .with_resolve_project(Ok(make_project_ref()))
1690 .with_create_dump(Ok(created_task(DumpStatus::InProgress)))
1691 .with_poll_sequence([Ok(make_dump_task(DumpStatus::Completed))])
1692 .with_download_bytes(b"zip".to_vec());
1693
1694 let mut renderer = RecordingRenderer::new();
1695 let mut reporter = RecordingProgressReporter::new();
1696
1697 run_impl(
1698 &args,
1699 &cfg,
1700 &client,
1701 &mut renderer,
1702 &mut reporter,
1703 Some("tok".to_string()),
1704 &no_op_sleeper(),
1705 fixed_now(),
1706 Some(&cache_path),
1707 dir.path(),
1708 )
1709 .unwrap();
1710
1711 let outcome = renderer.dump_outcome.unwrap();
1712 let expected_path = dir.path().join("0001-20260529T120000Z.zip");
1714 assert_eq!(outcome.path, expected_path);
1715 }
1716
1717 #[test]
1718 fn explicit_output_override_respected() {
1719 let dir = TempDir::new().unwrap();
1720 let out_path = dir.path().join("custom.zip");
1721 let (mut args, cfg) = make_args(&dir);
1722 args.output = Some(out_path.clone());
1723
1724 let client = MockDspClient::new()
1725 .with_resolve_project(Ok(make_project_ref()))
1726 .with_create_dump(Ok(created_task(DumpStatus::InProgress)))
1727 .with_poll_sequence([Ok(make_dump_task(DumpStatus::Completed))])
1728 .with_download_bytes(b"data".to_vec());
1729
1730 let mut renderer = RecordingRenderer::new();
1731 let mut reporter = RecordingProgressReporter::new();
1732
1733 run_impl(
1734 &args,
1735 &cfg,
1736 &client,
1737 &mut renderer,
1738 &mut reporter,
1739 Some("tok".to_string()),
1740 &no_op_sleeper(),
1741 fixed_now(),
1742 None,
1743 dir.path(),
1744 )
1745 .unwrap();
1746
1747 let outcome = renderer.dump_outcome.unwrap();
1748 assert_eq!(outcome.path, out_path);
1749 assert!(out_path.exists());
1750 }
1751
1752 #[test]
1753 fn explicit_output_exists_no_force_returns_usage_before_any_client_call() {
1754 let dir = TempDir::new().unwrap();
1755 let out_path = dir.path().join("existing.zip");
1756 std::fs::write(&out_path, b"existing").unwrap();
1757
1758 let (mut args, cfg) = make_args(&dir);
1759 args.output = Some(out_path.clone());
1760 args.force = false;
1761
1762 let client = MockDspClient::new();
1764
1765 let mut renderer = RecordingRenderer::new();
1766 let mut reporter = RecordingProgressReporter::new();
1767
1768 let err = run_impl(
1769 &args,
1770 &cfg,
1771 &client,
1772 &mut renderer,
1773 &mut reporter,
1774 Some("tok".to_string()),
1775 &no_op_sleeper(),
1776 fixed_now(),
1777 None,
1778 dir.path(),
1779 )
1780 .unwrap_err();
1781
1782 assert!(matches!(err, Diagnostic::Usage(_)), "expected Usage, got {err:?}");
1783 assert!(
1784 err.to_string().contains("refusing to overwrite"),
1785 "message should mention overwrite refusal: {err}"
1786 );
1787 assert_eq!(*client.resolve_calls.borrow(), 0, "resolve_project must not be called");
1789 assert_eq!(*client.create_calls.borrow(), 0, "create must not be called");
1790 }
1791
1792 #[test]
1793 fn default_output_exists_no_force_returns_usage_after_resolve_before_trigger() {
1794 let dir = TempDir::new().unwrap();
1795 let cache_path = dir.path().join("auth.toml");
1796
1797 let args = ProjectDumpArgs {
1798 server: Some("https://api.test.dasch.swiss".to_string()),
1799 project: Some("0001".to_string()),
1800 skip_assets: false,
1801 output: None,
1802 force: false,
1803 cleanup: false,
1804 timeout: 3600,
1805 replace: false,
1806 delete: false,
1807 discard_other_project: false,
1808 format: FormatArgs {
1809 format: Format::Prose,
1810 json: false,
1811 lines: false,
1812 columns: None,
1813 no_header: false,
1814 header_only: false,
1815 },
1816 };
1817 let cfg = Config { server: "https://api.test.dasch.swiss".to_string() };
1818
1819 let client = MockDspClient::new().with_resolve_project(Ok(make_project_ref()));
1820 let mut renderer = RecordingRenderer::new();
1821 let mut reporter = RecordingProgressReporter::new();
1822
1823 let default_path_in_tempdir = dir.path().join("0001-20260529T120000Z.zip");
1826 std::fs::write(&default_path_in_tempdir, b"existing")
1827 .expect("must be able to write conflict file into tempdir");
1828
1829 let err = run_impl(
1830 &args,
1831 &cfg,
1832 &client,
1833 &mut renderer,
1834 &mut reporter,
1835 Some("tok".to_string()),
1836 &no_op_sleeper(),
1837 fixed_now(),
1838 Some(&cache_path),
1839 dir.path(),
1840 )
1841 .unwrap_err();
1842
1843 assert!(matches!(err, Diagnostic::Usage(_)), "expected Usage, got {err:?}");
1845 assert!(
1846 err.to_string().contains("refusing to overwrite"),
1847 "message should mention overwrite refusal: {err}"
1848 );
1849 assert_eq!(
1850 *client.resolve_calls.borrow(),
1851 1,
1852 "resolve must have been called (default-path guard runs after resolve)"
1853 );
1854 assert_eq!(
1855 *client.create_calls.borrow(),
1856 0,
1857 "trigger must NOT have been called (guard fires before trigger)"
1858 );
1859 }
1860
1861 #[test]
1862 fn missing_token_returns_auth_required_with_no_client_calls() {
1863 let dir = TempDir::new().unwrap();
1864 let cache_path = dir.path().join("auth.toml");
1865 let (args, cfg) = make_args(&dir);
1866
1867 let client = MockDspClient::new();
1868 let mut renderer = RecordingRenderer::new();
1869 let mut reporter = RecordingProgressReporter::new();
1870
1871 let err = run_impl(
1873 &args,
1874 &cfg,
1875 &client,
1876 &mut renderer,
1877 &mut reporter,
1878 None, &no_op_sleeper(),
1880 fixed_now(),
1881 Some(&cache_path), dir.path(),
1883 )
1884 .unwrap_err();
1885
1886 assert!(matches!(err, Diagnostic::AuthRequired(_)), "expected AuthRequired, got {err:?}");
1887 assert_eq!(*client.resolve_calls.borrow(), 0, "resolve must not be called");
1888 assert_eq!(*client.create_calls.borrow(), 0, "trigger must not be called");
1889 }
1890
1891 #[test]
1892 fn trigger_conflict_propagates() {
1893 let dir = TempDir::new().unwrap();
1894 let (args, cfg) = make_args(&dir);
1895
1896 let client = MockDspClient::new()
1897 .with_resolve_project(Ok(make_project_ref()))
1898 .with_create_dump(Err(Diagnostic::Conflict(
1899 "a dump for this project is already in progress or present".to_string(),
1900 )));
1901
1902 let mut renderer = RecordingRenderer::new();
1903 let mut reporter = RecordingProgressReporter::new();
1904
1905 let err = run_impl(
1906 &args,
1907 &cfg,
1908 &client,
1909 &mut renderer,
1910 &mut reporter,
1911 Some("tok".to_string()),
1912 &no_op_sleeper(),
1913 fixed_now(),
1914 None,
1915 dir.path(),
1916 )
1917 .unwrap_err();
1918
1919 assert!(matches!(err, Diagnostic::Conflict(_)), "expected Conflict, got {err:?}");
1920 }
1921
1922 #[test]
1923 fn poll_failed_returns_server_error() {
1924 let dir = TempDir::new().unwrap();
1925 let (args, cfg) = make_args(&dir);
1926
1927 let failed_task = DumpTask {
1928 id: "dump-id-42".to_string(),
1929 status: DumpStatus::Failed,
1930 error_message: Some("out of disk space".to_string()),
1931 created_at: None,
1932 };
1933 let client = MockDspClient::new()
1934 .with_resolve_project(Ok(make_project_ref()))
1935 .with_create_dump(Ok(created_task(DumpStatus::InProgress)))
1936 .with_poll_sequence([Ok(failed_task)]);
1937
1938 let mut renderer = RecordingRenderer::new();
1939 let mut reporter = RecordingProgressReporter::new();
1940
1941 let err = run_impl(
1942 &args,
1943 &cfg,
1944 &client,
1945 &mut renderer,
1946 &mut reporter,
1947 Some("tok".to_string()),
1948 &no_op_sleeper(),
1949 fixed_now(),
1950 None,
1951 dir.path(),
1952 )
1953 .unwrap_err();
1954
1955 assert!(matches!(err, Diagnostic::ServerError(_)), "expected ServerError, got {err:?}");
1956 let msg = err.to_string();
1957 assert!(
1958 msg.contains("server-side dump failed"),
1959 "message should mention dump failure: {msg}"
1960 );
1961 assert!(msg.contains("out of disk space"), "message should include error_message: {msg}");
1962 }
1963
1964 #[test]
1965 fn timeout_via_logical_clock_returns_server_error() {
1966 let dir = TempDir::new().unwrap();
1967 let (mut args, cfg) = make_args(&dir);
1968 args.timeout = 1;
1971
1972 let in_progress: Vec<Result<DumpTask, Diagnostic>> =
1974 (0..100).map(|_| Ok(make_dump_task(DumpStatus::InProgress))).collect();
1975
1976 let client = MockDspClient::new()
1977 .with_resolve_project(Ok(make_project_ref()))
1978 .with_create_dump(Ok(created_task(DumpStatus::InProgress)))
1979 .with_poll_sequence(in_progress);
1980
1981 let mut renderer = RecordingRenderer::new();
1982 let mut reporter = RecordingProgressReporter::new();
1983
1984 let err = run_impl(
1985 &args,
1986 &cfg,
1987 &client,
1988 &mut renderer,
1989 &mut reporter,
1990 Some("tok".to_string()),
1991 &no_op_sleeper(),
1992 fixed_now(),
1993 None,
1994 dir.path(),
1995 )
1996 .unwrap_err();
1997
1998 assert!(
1999 matches!(err, Diagnostic::ServerError(_)),
2000 "expected ServerError timeout, got {err:?}"
2001 );
2002 let msg = err.to_string();
2003 assert!(msg.contains("did not complete"), "message should mention timeout: {msg}");
2004 assert!(
2005 msg.contains("may still be running"),
2006 "message should contain user-visible hint 'may still be running': {msg}"
2007 );
2008 }
2009
2010 #[test]
2011 fn cleanup_success_sets_cleaned_up_true() {
2012 let dir = TempDir::new().unwrap();
2013 let (mut args, cfg) = make_args(&dir);
2014 args.cleanup = true;
2015
2016 let client = MockDspClient::new()
2017 .with_resolve_project(Ok(make_project_ref()))
2018 .with_create_dump(Ok(created_task(DumpStatus::InProgress)))
2019 .with_poll_sequence([Ok(make_dump_task(DumpStatus::Completed))])
2020 .with_download_bytes(b"zip".to_vec())
2021 .with_delete_result(Ok(()));
2022
2023 let mut renderer = RecordingRenderer::new();
2024 let mut reporter = RecordingProgressReporter::new();
2025
2026 run_impl(
2027 &args,
2028 &cfg,
2029 &client,
2030 &mut renderer,
2031 &mut reporter,
2032 Some("tok".to_string()),
2033 &no_op_sleeper(),
2034 fixed_now(),
2035 None,
2036 dir.path(),
2037 )
2038 .unwrap();
2039
2040 let outcome = renderer.dump_outcome.unwrap();
2041 assert!(outcome.cleaned_up, "cleanup success should set cleaned_up=true");
2042 }
2043
2044 #[test]
2045 fn cleanup_error_keeps_exit_ok_and_cleaned_up_false() {
2046 let dir = TempDir::new().unwrap();
2047 let (mut args, cfg) = make_args(&dir);
2048 args.cleanup = true;
2049
2050 let client = MockDspClient::new()
2051 .with_resolve_project(Ok(make_project_ref()))
2052 .with_create_dump(Ok(created_task(DumpStatus::InProgress)))
2053 .with_poll_sequence([Ok(make_dump_task(DumpStatus::Completed))])
2054 .with_download_bytes(b"zip".to_vec())
2055 .with_delete_result(Err(Diagnostic::ServerError("delete failed".to_string())));
2056
2057 let mut renderer = RecordingRenderer::new();
2058 let mut reporter = RecordingProgressReporter::new();
2059
2060 run_impl(
2062 &args,
2063 &cfg,
2064 &client,
2065 &mut renderer,
2066 &mut reporter,
2067 Some("tok".to_string()),
2068 &no_op_sleeper(),
2069 fixed_now(),
2070 None,
2071 dir.path(),
2072 )
2073 .unwrap();
2074
2075 let outcome = renderer.dump_outcome.unwrap();
2076 assert!(!outcome.cleaned_up, "cleanup error should set cleaned_up=false");
2077 }
2078
2079 #[test]
2080 fn download_error_leaves_no_file_at_target_path() {
2081 let dir = TempDir::new().unwrap();
2082 let out_path = dir.path().join("should-not-exist.zip");
2083 let (mut args, cfg) = make_args(&dir);
2084 args.output = Some(out_path.clone());
2085
2086 let client = MockDspClient::new()
2087 .with_resolve_project(Ok(make_project_ref()))
2088 .with_create_dump(Ok(created_task(DumpStatus::InProgress)))
2089 .with_poll_sequence([Ok(make_dump_task(DumpStatus::Completed))])
2090 .with_download_error(Diagnostic::Network("connection reset".to_string()));
2091
2092 let mut renderer = RecordingRenderer::new();
2093 let mut reporter = RecordingProgressReporter::new();
2094
2095 let err = run_impl(
2096 &args,
2097 &cfg,
2098 &client,
2099 &mut renderer,
2100 &mut reporter,
2101 Some("tok".to_string()),
2102 &no_op_sleeper(),
2103 fixed_now(),
2104 None,
2105 dir.path(),
2106 )
2107 .unwrap_err();
2108
2109 assert!(matches!(err, Diagnostic::Network(_)), "expected Network error, got {err:?}");
2110 assert!(!out_path.exists(), "target file must not exist after download error");
2111 }
2112
2113 #[test]
2114 fn default_output_path_pure_fn() {
2115 let now = Utc.with_ymd_and_hms(2026, 5, 29, 12, 0, 0).unwrap();
2116 let base = std::path::Path::new("/tmp/test-base");
2117 let path = default_output_path(base, "0001", now);
2118 assert_eq!(path, PathBuf::from("/tmp/test-base/0001-20260529T120000Z.zip"));
2119 }
2120
2121 #[test]
2122 fn auth_state_env_token_reports_authenticated_via_dsp_token() {
2123 let dir = TempDir::new().unwrap();
2124 let (args, cfg) = make_args(&dir);
2125
2126 let client = MockDspClient::new()
2127 .with_resolve_project(Ok(make_project_ref()))
2128 .with_create_dump(Ok(created_task(DumpStatus::InProgress)))
2129 .with_poll_sequence([Ok(make_dump_task(DumpStatus::Completed))])
2130 .with_download_bytes(b"zip".to_vec());
2131
2132 let mut renderer = RecordingRenderer::new();
2133 let mut reporter = RecordingProgressReporter::new();
2134
2135 run_impl(
2136 &args,
2137 &cfg,
2138 &client,
2139 &mut renderer,
2140 &mut reporter,
2141 Some("env-token".to_string()),
2142 &no_op_sleeper(),
2143 fixed_now(),
2144 None,
2145 dir.path(),
2146 )
2147 .unwrap();
2148
2149 let meta = renderer.dump_meta.unwrap();
2150 assert_eq!(meta.auth_state, "authenticated via DSP_TOKEN");
2151 }
2152
2153 #[test]
2154 fn auth_state_cache_token_reports_authenticated() {
2155 use crate::config::AuthCache;
2156 use crate::config::auth_cache::ServerEntry;
2157
2158 let dir = TempDir::new().unwrap();
2159 let cache_path = dir.path().join("auth.toml");
2160 let out_path = dir.path().join("out.zip");
2161
2162 let mut cache = AuthCache::default();
2164 cache.set_entry(
2165 "https://api.test.dasch.swiss",
2166 ServerEntry {
2167 token: "cache-tok".to_string(),
2168 user: None,
2169 acquired_at: None,
2170 expires_at: None,
2171 },
2172 );
2173 cache.save_to(&cache_path).unwrap();
2174
2175 let args = ProjectDumpArgs {
2176 server: Some("https://api.test.dasch.swiss".to_string()),
2177 project: Some("0001".to_string()),
2178 skip_assets: false,
2179 output: Some(out_path),
2180 force: false,
2181 cleanup: false,
2182 timeout: 3600,
2183 replace: false,
2184 delete: false,
2185 discard_other_project: false,
2186 format: FormatArgs {
2187 format: Format::Prose,
2188 json: false,
2189 lines: false,
2190 columns: None,
2191 no_header: false,
2192 header_only: false,
2193 },
2194 };
2195 let cfg = Config { server: "https://api.test.dasch.swiss".to_string() };
2196
2197 let client = MockDspClient::new()
2198 .with_resolve_project(Ok(make_project_ref()))
2199 .with_create_dump(Ok(created_task(DumpStatus::InProgress)))
2200 .with_poll_sequence([Ok(make_dump_task(DumpStatus::Completed))])
2201 .with_download_bytes(b"zip".to_vec());
2202
2203 let mut renderer = RecordingRenderer::new();
2204 let mut reporter = RecordingProgressReporter::new();
2205
2206 run_impl(
2207 &args,
2208 &cfg,
2209 &client,
2210 &mut renderer,
2211 &mut reporter,
2212 None, &no_op_sleeper(),
2214 fixed_now(),
2215 Some(&cache_path),
2216 dir.path(),
2217 )
2218 .unwrap();
2219
2220 let meta = renderer.dump_meta.unwrap();
2222 assert_eq!(meta.auth_state, "authenticated");
2223 }
2224
2225 #[test]
2226 fn rename_failure_returns_io_and_temp_cleaned_up() {
2227 let dir = TempDir::new().unwrap();
2231 let final_path = dir.path().join("dump_dir");
2233 std::fs::create_dir(&final_path).unwrap();
2234
2235 let (mut args, cfg) = make_args(&dir);
2236 args.output = Some(final_path.clone());
2237 args.force = true; let client = MockDspClient::new()
2240 .with_resolve_project(Ok(make_project_ref()))
2241 .with_create_dump(Ok(created_task(DumpStatus::InProgress)))
2242 .with_poll_sequence([Ok(make_dump_task(DumpStatus::Completed))])
2243 .with_download_bytes(b"zip".to_vec());
2244
2245 let mut renderer = RecordingRenderer::new();
2246 let mut reporter = RecordingProgressReporter::new();
2247
2248 let err = run_impl(
2249 &args,
2250 &cfg,
2251 &client,
2252 &mut renderer,
2253 &mut reporter,
2254 Some("tok".to_string()),
2255 &no_op_sleeper(),
2256 fixed_now(),
2257 None,
2258 dir.path(),
2259 )
2260 .unwrap_err();
2261
2262 assert!(
2264 matches!(err, Diagnostic::Io(_)),
2265 "expected Io error for rename failure, got {err:?}"
2266 );
2267
2268 let pid = std::process::id();
2270 let temp = dir.path().join(format!("dump_dir.{pid}.partial"));
2271 assert!(!temp.exists(), "temp file should be cleaned up after rename failure");
2272 }
2273
2274 fn assert_download_error_leaves_no_file(download_error: Diagnostic) {
2280 let dir = TempDir::new().unwrap();
2281 let out_path = dir.path().join("should-not-exist.zip");
2282 let (mut args, cfg) = make_args(&dir);
2283 args.output = Some(out_path.clone());
2284
2285 let client = MockDspClient::new()
2286 .with_resolve_project(Ok(make_project_ref()))
2287 .with_create_dump(Ok(created_task(DumpStatus::InProgress)))
2288 .with_poll_sequence([Ok(make_dump_task(DumpStatus::Completed))])
2289 .with_download_error(download_error);
2290
2291 let mut renderer = RecordingRenderer::new();
2292 let mut reporter = RecordingProgressReporter::new();
2293
2294 let err = run_impl(
2295 &args,
2296 &cfg,
2297 &client,
2298 &mut renderer,
2299 &mut reporter,
2300 Some("tok".to_string()),
2301 &no_op_sleeper(),
2302 fixed_now(),
2303 None,
2304 dir.path(),
2305 )
2306 .unwrap_err();
2307
2308 assert!(!err.to_string().is_empty(), "error message must be non-empty");
2310 assert!(!out_path.exists(), "target file must not exist after download error ({err:?})");
2311 }
2312
2313 #[test]
2314 fn download_io_error_leaves_no_file_at_target_path() {
2315 assert_download_error_leaves_no_file(Diagnostic::Io(
2316 "failed to write /tmp/test.zip: no space left on device".to_string(),
2317 ));
2318 }
2319
2320 #[test]
2323 fn skip_assets_true_is_passed_through_to_create_project_dump() {
2324 let dir = TempDir::new().unwrap();
2325 let (mut args, cfg) = make_args(&dir);
2326 args.skip_assets = true;
2327
2328 let client = MockDspClient::new()
2329 .with_resolve_project(Ok(make_project_ref()))
2330 .with_create_dump(Ok(created_task(DumpStatus::InProgress)))
2331 .with_poll_sequence([Ok(make_dump_task(DumpStatus::Completed))])
2332 .with_download_bytes(b"zip".to_vec());
2333
2334 let mut renderer = RecordingRenderer::new();
2335 let mut reporter = RecordingProgressReporter::new();
2336
2337 run_impl(
2338 &args,
2339 &cfg,
2340 &client,
2341 &mut renderer,
2342 &mut reporter,
2343 Some("tok".to_string()),
2344 &no_op_sleeper(),
2345 fixed_now(),
2346 None,
2347 dir.path(),
2348 )
2349 .unwrap();
2350
2351 assert_eq!(
2352 client.create_skip_assets.get(),
2353 Some(true),
2354 "skip_assets=true must be forwarded to create_project_dump"
2355 );
2356 }
2357
2358 #[test]
2359 fn skip_assets_false_is_passed_through_to_create_project_dump() {
2360 let dir = TempDir::new().unwrap();
2361 let (mut args, cfg) = make_args(&dir);
2362 args.skip_assets = false;
2363
2364 let client = MockDspClient::new()
2365 .with_resolve_project(Ok(make_project_ref()))
2366 .with_create_dump(Ok(created_task(DumpStatus::InProgress)))
2367 .with_poll_sequence([Ok(make_dump_task(DumpStatus::Completed))])
2368 .with_download_bytes(b"zip".to_vec());
2369
2370 let mut renderer = RecordingRenderer::new();
2371 let mut reporter = RecordingProgressReporter::new();
2372
2373 run_impl(
2374 &args,
2375 &cfg,
2376 &client,
2377 &mut renderer,
2378 &mut reporter,
2379 Some("tok".to_string()),
2380 &no_op_sleeper(),
2381 fixed_now(),
2382 None,
2383 dir.path(),
2384 )
2385 .unwrap();
2386
2387 assert_eq!(
2388 client.create_skip_assets.get(),
2389 Some(false),
2390 "skip_assets=false must be forwarded to create_project_dump"
2391 );
2392 }
2393
2394 #[test]
2399 fn default_fresh_created_no_existing_dump() {
2400 use chrono::TimeZone;
2402 let dir = TempDir::new().unwrap();
2403 let (args, cfg) = make_args(&dir);
2404 let ts = Utc.with_ymd_and_hms(2026, 5, 20, 14, 3, 0).unwrap();
2405
2406 let client = MockDspClient::new()
2407 .with_resolve_project(Ok(make_project_ref()))
2408 .with_create_dump(Ok(CreateDumpOutcome::Created(DumpTask {
2409 id: "dump-id-42".into(),
2410 status: DumpStatus::InProgress,
2411 error_message: None,
2412 created_at: Some(ts),
2413 })))
2414 .with_poll_sequence([Ok(make_dump_task(DumpStatus::Completed))])
2415 .with_download_bytes(b"zipdata".to_vec());
2416
2417 let mut renderer = RecordingRenderer::new();
2418 let mut reporter = RecordingProgressReporter::new();
2419
2420 run_impl(
2421 &args,
2422 &cfg,
2423 &client,
2424 &mut renderer,
2425 &mut reporter,
2426 Some("tok".to_string()),
2427 &no_op_sleeper(),
2428 fixed_now(),
2429 None,
2430 dir.path(),
2431 )
2432 .unwrap();
2433
2434 let outcome = renderer.dump_outcome.unwrap();
2435 assert!(!outcome.reused, "fresh dump must have reused:false");
2436 assert_eq!(outcome.created_at, Some(ts), "created_at must be populated from task");
2437 assert_eq!(outcome.bytes, 7); let log = client.call_log();
2440 assert!(log.contains(&CallRecord::Resolve));
2441 assert!(log.contains(&CallRecord::Create { project_iri: "http://rdfh.ch/projects/0001".to_string() }));
2442 assert!(log.contains(&CallRecord::Poll));
2443 assert!(log.contains(&CallRecord::Download));
2444 }
2445
2446 #[test]
2447 fn default_adopt_completed_existing_dump() {
2448 use chrono::TimeZone;
2450 let dir = TempDir::new().unwrap();
2451 let (args, cfg) = make_args(&dir);
2452 let ts = Utc.with_ymd_and_hms(2026, 5, 15, 10, 0, 0).unwrap();
2453
2454 let existing_task = DumpTask {
2455 id: "existing-id".into(),
2456 status: DumpStatus::Completed,
2457 error_message: None,
2458 created_at: Some(ts),
2459 };
2460
2461 let client = MockDspClient::new()
2462 .with_resolve_project(Ok(make_project_ref()))
2463 .with_create_exists("existing-id")
2464 .with_status_sequence([Ok(existing_task)])
2465 .with_download_bytes(b"existing".to_vec());
2466
2467 let mut renderer = RecordingRenderer::new();
2468 let mut reporter = RecordingProgressReporter::new();
2469
2470 run_impl(
2471 &args,
2472 &cfg,
2473 &client,
2474 &mut renderer,
2475 &mut reporter,
2476 Some("tok".to_string()),
2477 &no_op_sleeper(),
2478 fixed_now(),
2479 None,
2480 dir.path(),
2481 )
2482 .unwrap();
2483
2484 let outcome = renderer.dump_outcome.unwrap();
2485 assert!(outcome.reused, "adopted dump must have reused:true");
2486 assert_eq!(outcome.created_at, Some(ts), "created_at must come from status");
2487 assert!(
2489 reporter.events.contains(&EventRecord::Adopting("existing-id".into())),
2490 "must report Adopting"
2491 );
2492 assert!(
2493 !reporter.events.iter().any(|e| matches!(e, EventRecord::Triggered(_))),
2494 "must NOT report Triggered when adopting"
2495 );
2496 let log = client.call_log();
2498 assert_eq!(log[0], CallRecord::Resolve);
2499 assert_eq!(
2500 log[1],
2501 CallRecord::Create { project_iri: "http://rdfh.ch/projects/0001".to_string() }
2502 );
2503 assert_eq!(log[2], CallRecord::Status("http://rdfh.ch/projects/0001".to_string()));
2504 assert_eq!(log[3], CallRecord::Download);
2505 }
2506
2507 #[test]
2508 fn default_adopt_in_progress_polls_then_downloads() {
2509 use chrono::TimeZone;
2511 let dir = TempDir::new().unwrap();
2512 let (args, cfg) = make_args(&dir);
2513 let ts = Utc.with_ymd_and_hms(2026, 5, 10, 8, 0, 0).unwrap();
2514
2515 let in_progress_task = DumpTask {
2516 id: "adopt-ip-id".into(),
2517 status: DumpStatus::InProgress,
2518 error_message: None,
2519 created_at: Some(ts),
2520 };
2521
2522 let client = MockDspClient::new()
2523 .with_resolve_project(Ok(make_project_ref()))
2524 .with_create_exists("adopt-ip-id")
2525 .with_status_sequence([Ok(in_progress_task)])
2528 .with_poll_sequence([
2529 Ok(make_dump_task(DumpStatus::InProgress)),
2530 Ok(make_dump_task(DumpStatus::Completed)),
2531 ])
2532 .with_download_bytes(b"data".to_vec());
2533
2534 let mut renderer = RecordingRenderer::new();
2535 let mut reporter = RecordingProgressReporter::new();
2536
2537 run_impl(
2538 &args,
2539 &cfg,
2540 &client,
2541 &mut renderer,
2542 &mut reporter,
2543 Some("tok".to_string()),
2544 &no_op_sleeper(),
2545 fixed_now(),
2546 None,
2547 dir.path(),
2548 )
2549 .unwrap();
2550
2551 let outcome = renderer.dump_outcome.unwrap();
2552 assert!(outcome.reused);
2553 assert_eq!(outcome.created_at, Some(ts));
2554 let log = client.call_log();
2557 assert_eq!(log[0], CallRecord::Resolve, "first call must be Resolve");
2558 assert_eq!(
2559 log[1],
2560 CallRecord::Create { project_iri: "http://rdfh.ch/projects/0001".to_string() },
2561 "second call must be Create"
2562 );
2563 assert_eq!(
2564 log[2],
2565 CallRecord::Status("http://rdfh.ch/projects/0001".to_string()),
2566 "third call must be Status"
2567 );
2568 assert_eq!(log[3], CallRecord::Poll, "fourth call must be Poll (in_progress)");
2569 assert_eq!(log[4], CallRecord::Poll, "fifth call must be Poll (completed)");
2570 assert_eq!(log[5], CallRecord::Download, "sixth call must be Download");
2571 assert_eq!(log.len(), 6, "must be exactly 6 calls");
2572 let adopting_idx = reporter
2574 .events
2575 .iter()
2576 .position(|e| matches!(e, EventRecord::Adopting(_)))
2577 .expect("Adopting event must be present");
2578 let first_polling_idx = reporter
2579 .events
2580 .iter()
2581 .position(|e| matches!(e, EventRecord::Polling(_)))
2582 .expect("Polling event must be present");
2583 assert!(
2584 adopting_idx < first_polling_idx,
2585 "Adopting must be reported before the first Polling event"
2586 );
2587 }
2588
2589 #[test]
2590 fn default_existing_failed_returns_conflict_with_hint() {
2591 let dir = TempDir::new().unwrap();
2592 let (args, cfg) = make_args(&dir);
2593
2594 let failed_task = DumpTask {
2595 id: "fail-id".into(),
2596 status: DumpStatus::Failed,
2597 error_message: Some("disk full".into()),
2598 created_at: None,
2599 };
2600
2601 let client = MockDspClient::new()
2602 .with_resolve_project(Ok(make_project_ref()))
2603 .with_create_exists("fail-id")
2604 .with_status_sequence([Ok(failed_task)]);
2605
2606 let mut renderer = RecordingRenderer::new();
2607 let mut reporter = RecordingProgressReporter::new();
2608
2609 let err = run_impl(
2610 &args,
2611 &cfg,
2612 &client,
2613 &mut renderer,
2614 &mut reporter,
2615 Some("tok".to_string()),
2616 &no_op_sleeper(),
2617 fixed_now(),
2618 None,
2619 dir.path(),
2620 )
2621 .unwrap_err();
2622
2623 assert!(
2624 matches!(err, Diagnostic::Conflict(_)),
2625 "failed existing dump must yield Conflict"
2626 );
2627 let msg = err.to_string();
2628 assert!(msg.contains("existing dump failed"), "message must mention failure: {msg}");
2629 assert!(msg.contains("disk full"), "message must include server error: {msg}");
2630 assert!(msg.contains("--replace"), "message must hint at --replace: {msg}");
2631 assert!(msg.contains("--delete"), "message must hint at --delete: {msg}");
2632 }
2633
2634 #[test]
2637 fn replace_none_existing_creates_fresh() {
2638 let dir = TempDir::new().unwrap();
2640 let (mut args, cfg) = make_args(&dir);
2641 args.replace = true;
2642
2643 let client = MockDspClient::new()
2644 .with_resolve_project(Ok(make_project_ref()))
2645 .with_create_dump(Ok(created_task(DumpStatus::InProgress)))
2646 .with_poll_sequence([Ok(make_dump_task(DumpStatus::Completed))])
2647 .with_download_bytes(b"fresh".to_vec());
2648
2649 let mut renderer = RecordingRenderer::new();
2650 let mut reporter = RecordingProgressReporter::new();
2651
2652 run_impl(
2653 &args,
2654 &cfg,
2655 &client,
2656 &mut renderer,
2657 &mut reporter,
2658 Some("tok".to_string()),
2659 &no_op_sleeper(),
2660 fixed_now(),
2661 None,
2662 dir.path(),
2663 )
2664 .unwrap();
2665
2666 let outcome = renderer.dump_outcome.unwrap();
2667 assert!(!outcome.reused, "replace with no existing → reused:false");
2668 assert_eq!(*client.create_calls.borrow(), 1);
2670 }
2671
2672 #[test]
2673 fn replace_completed_deletes_then_recreates() {
2674 let dir = TempDir::new().unwrap();
2676 let (mut args, cfg) = make_args(&dir);
2677 args.replace = true;
2678
2679 let existing_task = DumpTask {
2680 id: "old-id".into(),
2681 status: DumpStatus::Completed,
2682 error_message: None,
2683 created_at: None,
2684 };
2685 let task2 = DumpTask {
2686 id: "new-id".into(),
2687 status: DumpStatus::InProgress,
2688 error_message: None,
2689 created_at: None,
2690 };
2691
2692 let client = MockDspClient::new()
2693 .with_resolve_project(Ok(make_project_ref()))
2694 .with_create_exists("old-id")
2695 .with_create_sequence([Ok(CreateDumpOutcome::Created(task2))])
2696 .with_status_sequence([Ok(existing_task)])
2697 .with_poll_sequence([Ok(make_dump_task(DumpStatus::Completed))])
2698 .with_delete_result(Ok(()))
2699 .with_download_bytes(b"new".to_vec());
2700
2701 let mut renderer = RecordingRenderer::new();
2702 let mut reporter = RecordingProgressReporter::new();
2703
2704 run_impl(
2705 &args,
2706 &cfg,
2707 &client,
2708 &mut renderer,
2709 &mut reporter,
2710 Some("tok".to_string()),
2711 &no_op_sleeper(),
2712 fixed_now(),
2713 None,
2714 dir.path(),
2715 )
2716 .unwrap();
2717
2718 let outcome = renderer.dump_outcome.unwrap();
2719 assert!(!outcome.reused, "replace always produces reused:false");
2720 let log = client.call_log();
2722 assert_eq!(log[0], CallRecord::Resolve, "first call must be Resolve");
2723 assert_eq!(
2724 log[1],
2725 CallRecord::Create { project_iri: "http://rdfh.ch/projects/0001".to_string() },
2726 "second call must be Create"
2727 );
2728 assert_eq!(
2729 log[2],
2730 CallRecord::Status("http://rdfh.ch/projects/0001".to_string()),
2731 "third call must be Status"
2732 );
2733 assert_eq!(
2734 log[3],
2735 CallRecord::Delete("http://rdfh.ch/projects/0001".to_string()),
2736 "fourth call must be Delete"
2737 );
2738 assert_eq!(
2739 log[4],
2740 CallRecord::Create { project_iri: "http://rdfh.ch/projects/0001".to_string() },
2741 "fifth call must be Create (2nd)"
2742 );
2743 assert_eq!(log[5], CallRecord::Poll, "sixth call must be Poll");
2744 assert_eq!(log[6], CallRecord::Download, "seventh call must be Download");
2745 assert_eq!(log.len(), 7, "must be exactly 7 calls");
2746 assert!(
2748 reporter.events.contains(&EventRecord::Deleting("old-id".into())),
2749 "must report Deleting for the old dump"
2750 );
2751 assert!(
2753 reporter.events.contains(&EventRecord::Triggered("new-id".into())),
2754 "must report Triggered for the new dump; events: {:?}",
2755 reporter.events
2756 );
2757 }
2758
2759 #[test]
2760 fn replace_failed_existing_deletes_then_recreates() {
2761 let dir = TempDir::new().unwrap();
2763 let (mut args, cfg) = make_args(&dir);
2764 args.replace = true;
2765
2766 let failed_task = DumpTask {
2767 id: "failed-old-id".into(),
2768 status: DumpStatus::Failed,
2769 error_message: Some("ran out of space".into()),
2770 created_at: None,
2771 };
2772 let task2 = DumpTask {
2773 id: "new-id-2".into(),
2774 status: DumpStatus::InProgress,
2775 error_message: None,
2776 created_at: None,
2777 };
2778
2779 let client = MockDspClient::new()
2780 .with_resolve_project(Ok(make_project_ref()))
2781 .with_create_exists("failed-old-id")
2782 .with_create_sequence([Ok(CreateDumpOutcome::Created(task2))])
2783 .with_status_sequence([Ok(failed_task)])
2784 .with_poll_sequence([Ok(make_dump_task(DumpStatus::Completed))])
2785 .with_delete_result(Ok(()))
2786 .with_download_bytes(b"new".to_vec());
2787
2788 let mut renderer = RecordingRenderer::new();
2789 let mut reporter = RecordingProgressReporter::new();
2790
2791 run_impl(
2792 &args,
2793 &cfg,
2794 &client,
2795 &mut renderer,
2796 &mut reporter,
2797 Some("tok".to_string()),
2798 &no_op_sleeper(),
2799 fixed_now(),
2800 None,
2801 dir.path(),
2802 )
2803 .unwrap();
2804
2805 let outcome = renderer.dump_outcome.unwrap();
2806 assert!(!outcome.reused);
2807 }
2808
2809 #[test]
2810 fn replace_recreate_race_returns_conflict() {
2811 let dir = TempDir::new().unwrap();
2813 let (mut args, cfg) = make_args(&dir);
2814 args.replace = true;
2815
2816 let existing_task = DumpTask {
2817 id: "race-id".into(),
2818 status: DumpStatus::Completed,
2819 error_message: None,
2820 created_at: None,
2821 };
2822
2823 let client = MockDspClient::new()
2824 .with_resolve_project(Ok(make_project_ref()))
2825 .with_create_exists("race-id")
2826 .with_create_sequence([Ok(CreateDumpOutcome::Exists { id: "race-id-2".into() })])
2827 .with_status_sequence([Ok(existing_task)])
2828 .with_delete_result(Ok(()));
2829
2830 let mut renderer = RecordingRenderer::new();
2831 let mut reporter = RecordingProgressReporter::new();
2832
2833 let err = run_impl(
2834 &args,
2835 &cfg,
2836 &client,
2837 &mut renderer,
2838 &mut reporter,
2839 Some("tok".to_string()),
2840 &no_op_sleeper(),
2841 fixed_now(),
2842 None,
2843 dir.path(),
2844 )
2845 .unwrap_err();
2846
2847 assert!(matches!(err, Diagnostic::Conflict(_)), "recreate race must yield Conflict");
2848 let msg = err.to_string();
2849 assert!(msg.contains("recreated"), "message must mention recreation: {msg}");
2850 }
2851
2852 #[test]
2853 fn replace_in_progress_returns_conflict() {
2854 let dir = TempDir::new().unwrap();
2856 let (mut args, cfg) = make_args(&dir);
2857 args.replace = true;
2858
2859 let in_progress_task = DumpTask {
2860 id: "ip-id".into(),
2861 status: DumpStatus::InProgress,
2862 error_message: None,
2863 created_at: None,
2864 };
2865
2866 let client = MockDspClient::new()
2867 .with_resolve_project(Ok(make_project_ref()))
2868 .with_create_exists("ip-id")
2869 .with_status_sequence([Ok(in_progress_task)]);
2870
2871 let mut renderer = RecordingRenderer::new();
2872 let mut reporter = RecordingProgressReporter::new();
2873
2874 let err = run_impl(
2875 &args,
2876 &cfg,
2877 &client,
2878 &mut renderer,
2879 &mut reporter,
2880 Some("tok".to_string()),
2881 &no_op_sleeper(),
2882 fixed_now(),
2883 None,
2884 dir.path(),
2885 )
2886 .unwrap_err();
2887
2888 assert!(
2889 matches!(err, Diagnostic::Conflict(_)),
2890 "in-progress existing dump must block replace"
2891 );
2892 let msg = err.to_string();
2893 assert!(msg.contains("in progress"), "message must mention in-progress state: {msg}");
2894 assert_eq!(*client.delete_calls.borrow(), 0, "delete must not be called when in-progress");
2896 }
2897
2898 #[test]
2901 fn delete_completed_deletes_without_downloading() {
2902 let dir = TempDir::new().unwrap();
2904 let (mut args, cfg) = make_args(&dir);
2905 args.delete = true;
2906
2907 let completed_task = DumpTask {
2908 id: "del-id".into(),
2909 status: DumpStatus::Completed,
2910 error_message: None,
2911 created_at: None,
2912 };
2913
2914 let client = MockDspClient::new()
2915 .with_resolve_project(Ok(make_project_ref()))
2916 .with_create_exists("del-id")
2917 .with_status_sequence([Ok(completed_task)])
2918 .with_delete_result(Ok(()));
2919
2920 let mut renderer = RecordingRenderer::new();
2921 let mut reporter = RecordingProgressReporter::new();
2922
2923 run_impl(
2924 &args,
2925 &cfg,
2926 &client,
2927 &mut renderer,
2928 &mut reporter,
2929 Some("tok".to_string()),
2930 &no_op_sleeper(),
2931 fixed_now(),
2932 None,
2933 dir.path(),
2934 )
2935 .unwrap();
2936
2937 assert_eq!(*client.download_calls.borrow(), 0, "delete must not download");
2939 assert_eq!(*client.delete_calls.borrow(), 1);
2941 let del_outcome = renderer
2943 .dump_deleted_outcome
2944 .expect("project_dump_deleted must have been called");
2945 assert!(del_outcome.deleted);
2946 assert!(del_outcome.note.is_none());
2947 assert!(
2949 renderer.dump_outcome.is_none(),
2950 "project_dump must not be called in delete mode"
2951 );
2952 assert!(reporter.events.contains(&EventRecord::Deleting("del-id".into())));
2954 let log = client.call_log();
2956 assert_eq!(log[0], CallRecord::Resolve);
2957 assert_eq!(
2958 log[1],
2959 CallRecord::Create { project_iri: "http://rdfh.ch/projects/0001".to_string() }
2960 );
2961 assert_eq!(log[2], CallRecord::Status("http://rdfh.ch/projects/0001".to_string()));
2962 assert_eq!(log[3], CallRecord::Delete("http://rdfh.ch/projects/0001".to_string()));
2963 assert_eq!(log.len(), 4, "must be exactly 4 calls");
2964 }
2965
2966 #[test]
2967 fn delete_failed_deletes_without_downloading() {
2968 let dir = TempDir::new().unwrap();
2972 let (mut args, cfg) = make_args(&dir);
2973 args.delete = true;
2974
2975 let failed_task = DumpTask {
2976 id: "del-failed-id".into(),
2977 status: DumpStatus::Failed,
2978 error_message: Some("disk full".into()),
2979 created_at: None,
2980 };
2981
2982 let client = MockDspClient::new()
2983 .with_resolve_project(Ok(make_project_ref()))
2984 .with_create_exists("del-failed-id")
2985 .with_status_sequence([Ok(failed_task)])
2986 .with_delete_result(Ok(()));
2987
2988 let mut renderer = RecordingRenderer::new();
2989 let mut reporter = RecordingProgressReporter::new();
2990
2991 run_impl(
2992 &args,
2993 &cfg,
2994 &client,
2995 &mut renderer,
2996 &mut reporter,
2997 Some("tok".to_string()),
2998 &no_op_sleeper(),
2999 fixed_now(),
3000 None,
3001 dir.path(),
3002 )
3003 .unwrap();
3004
3005 assert_eq!(
3007 *client.download_calls.borrow(),
3008 0,
3009 "delete must not download even for a failed dump"
3010 );
3011 assert_eq!(*client.delete_calls.borrow(), 1, "delete must be called for a failed dump");
3013 let del_outcome = renderer
3015 .dump_deleted_outcome
3016 .expect("project_dump_deleted must have been called");
3017 assert!(del_outcome.deleted, "deleted must be true for failed dump");
3018 assert!(del_outcome.note.is_none());
3019 assert!(
3021 renderer.dump_outcome.is_none(),
3022 "project_dump must not be called in delete mode"
3023 );
3024 let log = client.call_log();
3026 assert_eq!(log[0], CallRecord::Resolve);
3027 assert_eq!(
3028 log[1],
3029 CallRecord::Create { project_iri: "http://rdfh.ch/projects/0001".to_string() }
3030 );
3031 assert_eq!(log[2], CallRecord::Status("http://rdfh.ch/projects/0001".to_string()));
3032 assert_eq!(log[3], CallRecord::Delete("http://rdfh.ch/projects/0001".to_string()));
3033 assert_eq!(log.len(), 4, "must be exactly 4 calls");
3034 }
3035
3036 #[test]
3037 fn delete_in_progress_returns_conflict() {
3038 let dir = TempDir::new().unwrap();
3040 let (mut args, cfg) = make_args(&dir);
3041 args.delete = true;
3042
3043 let in_progress_task = DumpTask {
3044 id: "del-ip-id".into(),
3045 status: DumpStatus::InProgress,
3046 error_message: None,
3047 created_at: None,
3048 };
3049
3050 let client = MockDspClient::new()
3051 .with_resolve_project(Ok(make_project_ref()))
3052 .with_create_exists("del-ip-id")
3053 .with_status_sequence([Ok(in_progress_task)]);
3054
3055 let mut renderer = RecordingRenderer::new();
3056 let mut reporter = RecordingProgressReporter::new();
3057
3058 let err = run_impl(
3059 &args,
3060 &cfg,
3061 &client,
3062 &mut renderer,
3063 &mut reporter,
3064 Some("tok".to_string()),
3065 &no_op_sleeper(),
3066 fixed_now(),
3067 None,
3068 dir.path(),
3069 )
3070 .unwrap_err();
3071
3072 assert!(matches!(err, Diagnostic::Conflict(_)), "in-progress dump must block delete");
3073 let msg = err.to_string();
3074 assert!(msg.contains("in progress"), "message must mention in-progress: {msg}");
3075 assert_eq!(*client.delete_calls.borrow(), 0, "delete must not be called");
3076 }
3077
3078 #[test]
3079 fn delete_none_probe_created_reports_probe_and_exits_ok() {
3080 let dir = TempDir::new().unwrap();
3083 let (mut args, cfg) = make_args(&dir);
3084 args.delete = true;
3085
3086 let client = MockDspClient::new()
3087 .with_resolve_project(Ok(make_project_ref()))
3088 .with_create_dump(Ok(created_task(DumpStatus::InProgress)));
3089
3090 let mut renderer = RecordingRenderer::new();
3091 let mut reporter = RecordingProgressReporter::new();
3092
3093 run_impl(
3094 &args,
3095 &cfg,
3096 &client,
3097 &mut renderer,
3098 &mut reporter,
3099 Some("tok".to_string()),
3100 &no_op_sleeper(),
3101 fixed_now(),
3102 None,
3103 dir.path(),
3104 )
3105 .unwrap(); assert!(renderer.dump_outcome.is_none());
3109 let del_outcome = renderer.dump_deleted_outcome.expect("project_dump_deleted must be called");
3111 assert!(!del_outcome.deleted, "deleted must be false (probe, not real delete)");
3112 let note = del_outcome.note.expect("note must be set for probe case");
3113 assert!(note.contains("dump-id-42"), "note must mention the probe id: {note}");
3114 assert!(
3116 reporter.events.contains(&EventRecord::ProbeCreated("dump-id-42".into())),
3117 "must report ProbeCreated; events: {:?}",
3118 reporter.events
3119 );
3120 assert_eq!(*client.download_calls.borrow(), 0);
3122 assert_eq!(*client.delete_calls.borrow(), 0);
3123 }
3124
3125 fn foreign_iri() -> &'static str {
3129 "http://rdfh.ch/projects/0002"
3130 }
3131
3132 #[test]
3133 fn default_exists_for_other_project_returns_conflict_no_server_calls() {
3134 let dir = TempDir::new().unwrap();
3136 let (args, cfg) = make_args(&dir);
3137
3138 let client = MockDspClient::new()
3139 .with_resolve_project(Ok(make_project_ref()))
3140 .with_create_exists_other_project("foreign-dump-id", foreign_iri());
3141
3142 let mut renderer = RecordingRenderer::new();
3143 let mut reporter = RecordingProgressReporter::new();
3144
3145 let err = run_impl(
3146 &args,
3147 &cfg,
3148 &client,
3149 &mut renderer,
3150 &mut reporter,
3151 Some("tok".to_string()),
3152 &no_op_sleeper(),
3153 fixed_now(),
3154 None,
3155 dir.path(),
3156 )
3157 .unwrap_err();
3158
3159 assert!(
3160 matches!(err, Diagnostic::Conflict(_)),
3161 "Default + ExistsForOtherProject must yield Conflict, got {err:?}"
3162 );
3163 let msg = err.to_string();
3164 assert!(msg.contains(foreign_iri()), "Conflict message must name the foreign IRI: {msg}");
3165 assert!(
3166 msg.contains("--replace --discard-other-project"),
3167 "Conflict message must hint at --replace --discard-other-project: {msg}"
3168 );
3169 let log = client.call_log();
3171 assert_eq!(log[0], CallRecord::Resolve);
3172 assert_eq!(
3173 log[1],
3174 CallRecord::Create { project_iri: "http://rdfh.ch/projects/0001".to_string() }
3175 );
3176 assert_eq!(log.len(), 2, "must be exactly 2 calls (no status/delete/download)");
3177 }
3178
3179 #[test]
3180 fn replace_exists_for_other_project_without_flag_returns_conflict_no_status_delete() {
3181 let dir = TempDir::new().unwrap();
3184 let (mut args, cfg) = make_args(&dir);
3185 args.replace = true;
3186 let client = MockDspClient::new()
3189 .with_resolve_project(Ok(make_project_ref()))
3190 .with_create_exists_other_project("foreign-dump-id", foreign_iri());
3191
3192 let mut renderer = RecordingRenderer::new();
3193 let mut reporter = RecordingProgressReporter::new();
3194
3195 let err = run_impl(
3196 &args,
3197 &cfg,
3198 &client,
3199 &mut renderer,
3200 &mut reporter,
3201 Some("tok".to_string()),
3202 &no_op_sleeper(),
3203 fixed_now(),
3204 None,
3205 dir.path(),
3206 )
3207 .unwrap_err();
3208
3209 assert!(
3210 matches!(err, Diagnostic::Conflict(_)),
3211 "Replace + ExistsForOtherProject without flag must yield Conflict, got {err:?}"
3212 );
3213 let msg = err.to_string();
3214 assert!(msg.contains(foreign_iri()), "Conflict message must name the foreign IRI: {msg}");
3215 assert!(
3216 msg.contains("--replace --discard-other-project"),
3217 "Conflict message must hint at the flag: {msg}"
3218 );
3219 let log = client.call_log();
3221 assert_eq!(log[0], CallRecord::Resolve);
3222 assert_eq!(
3223 log[1],
3224 CallRecord::Create { project_iri: "http://rdfh.ch/projects/0001".to_string() }
3225 );
3226 assert_eq!(log.len(), 2, "must be exactly 2 calls (no status/delete)");
3227 }
3228
3229 #[test]
3230 fn replace_exists_for_other_project_with_flag_foreign_completed_discards_and_recreates() {
3231 let dir = TempDir::new().unwrap();
3234 let (mut args, cfg) = make_args(&dir);
3235 args.replace = true;
3236 args.discard_other_project = true;
3237
3238 let foreign_task = DumpTask {
3239 id: "foreign-dump-id".into(),
3240 status: DumpStatus::Completed,
3241 error_message: None,
3242 created_at: None,
3243 };
3244 let new_task = DumpTask {
3245 id: "new-dump-id".into(),
3246 status: DumpStatus::InProgress,
3247 error_message: None,
3248 created_at: None,
3249 };
3250
3251 let client = MockDspClient::new()
3252 .with_resolve_project(Ok(make_project_ref()))
3253 .with_create_exists_other_project("foreign-dump-id", foreign_iri())
3254 .with_status_sequence([Ok(foreign_task)])
3256 .with_create_sequence([Ok(CreateDumpOutcome::Created(new_task))])
3258 .with_delete_result(Ok(()))
3259 .with_poll_sequence([Ok(make_dump_task(DumpStatus::Completed))])
3260 .with_download_bytes(b"dump-data".to_vec());
3261
3262 let mut renderer = RecordingRenderer::new();
3263 let mut reporter = RecordingProgressReporter::new();
3264
3265 run_impl(
3266 &args,
3267 &cfg,
3268 &client,
3269 &mut renderer,
3270 &mut reporter,
3271 Some("tok".to_string()),
3272 &no_op_sleeper(),
3273 fixed_now(),
3274 None,
3275 dir.path(),
3276 )
3277 .unwrap();
3278
3279 assert!(
3281 reporter.events.contains(&EventRecord::DiscardingOtherProjectDump {
3282 id: "foreign-dump-id".into(),
3283 project_iri: foreign_iri().to_string(),
3284 }),
3285 "must report DiscardingOtherProjectDump; events: {:?}",
3286 reporter.events
3287 );
3288
3289 let log = client.call_log();
3292 assert_eq!(log[0], CallRecord::Resolve, "first must be Resolve");
3293 assert_eq!(
3294 log[1],
3295 CallRecord::Create { project_iri: make_project_ref().iri },
3296 "second must be Create with REQUESTED project IRI (initial probe)"
3297 );
3298 assert_eq!(
3299 log[2],
3300 CallRecord::Status(foreign_iri().to_string()),
3301 "third must be Status with FOREIGN iri"
3302 );
3303 assert_eq!(
3304 log[3],
3305 CallRecord::Delete(foreign_iri().to_string()),
3306 "fourth must be Delete with FOREIGN iri"
3307 );
3308 assert_eq!(
3309 log[4],
3310 CallRecord::Create { project_iri: make_project_ref().iri },
3311 "fifth must be Create with REQUESTED project IRI (recreate after discard)"
3312 );
3313 assert_eq!(log[5], CallRecord::Poll, "sixth must be Poll");
3314 assert_eq!(log[6], CallRecord::Download, "seventh must be Download");
3315 assert_eq!(log.len(), 7, "must be exactly 7 calls");
3316
3317 assert!(
3319 renderer.dump_outcome.is_some(),
3320 "project_dump must be called after successful discard+recreate"
3321 );
3322 }
3323
3324 #[test]
3325 fn replace_exists_for_other_project_with_flag_foreign_failed_discards_and_recreates() {
3326 let dir = TempDir::new().unwrap();
3330 let (mut args, cfg) = make_args(&dir);
3331 args.replace = true;
3332 args.discard_other_project = true;
3333
3334 let foreign_task = DumpTask {
3335 id: "foreign-dump-id".into(),
3336 status: DumpStatus::Failed,
3337 error_message: Some("out of disk space".into()),
3338 created_at: None,
3339 };
3340 let new_task = DumpTask {
3341 id: "new-dump-id".into(),
3342 status: DumpStatus::InProgress,
3343 error_message: None,
3344 created_at: None,
3345 };
3346
3347 let client = MockDspClient::new()
3348 .with_resolve_project(Ok(make_project_ref()))
3349 .with_create_exists_other_project("foreign-dump-id", foreign_iri())
3350 .with_status_sequence([Ok(foreign_task)])
3352 .with_create_sequence([Ok(CreateDumpOutcome::Created(new_task))])
3354 .with_delete_result(Ok(()))
3355 .with_poll_sequence([Ok(make_dump_task(DumpStatus::Completed))])
3356 .with_download_bytes(b"dump-data".to_vec());
3357
3358 let mut renderer = RecordingRenderer::new();
3359 let mut reporter = RecordingProgressReporter::new();
3360
3361 run_impl(
3362 &args,
3363 &cfg,
3364 &client,
3365 &mut renderer,
3366 &mut reporter,
3367 Some("tok".to_string()),
3368 &no_op_sleeper(),
3369 fixed_now(),
3370 None,
3371 dir.path(),
3372 )
3373 .unwrap();
3374
3375 assert!(
3377 reporter.events.contains(&EventRecord::DiscardingOtherProjectDump {
3378 id: "foreign-dump-id".into(),
3379 project_iri: foreign_iri().to_string(),
3380 }),
3381 "must report DiscardingOtherProjectDump; events: {:?}",
3382 reporter.events
3383 );
3384
3385 let log = client.call_log();
3388 assert_eq!(log[0], CallRecord::Resolve, "first must be Resolve");
3389 assert_eq!(
3390 log[1],
3391 CallRecord::Create { project_iri: make_project_ref().iri },
3392 "second must be Create with REQUESTED project IRI (initial probe)"
3393 );
3394 assert_eq!(
3395 log[2],
3396 CallRecord::Status(foreign_iri().to_string()),
3397 "third must be Status with FOREIGN iri"
3398 );
3399 assert_eq!(
3400 log[3],
3401 CallRecord::Delete(foreign_iri().to_string()),
3402 "fourth must be Delete with FOREIGN iri"
3403 );
3404 assert_eq!(
3405 log[4],
3406 CallRecord::Create { project_iri: make_project_ref().iri },
3407 "fifth must be Create with REQUESTED project IRI (recreate after discard)"
3408 );
3409 assert_eq!(log[5], CallRecord::Poll, "sixth must be Poll");
3410 assert_eq!(log[6], CallRecord::Download, "seventh must be Download");
3411 assert_eq!(log.len(), 7, "must be exactly 7 calls");
3412
3413 assert!(
3415 renderer.dump_outcome.is_some(),
3416 "project_dump must be called after successful discard+recreate"
3417 );
3418 }
3419
3420 #[test]
3421 fn replace_exists_for_other_project_with_flag_foreign_in_progress_returns_conflict() {
3422 let dir = TempDir::new().unwrap();
3425 let (mut args, cfg) = make_args(&dir);
3426 args.replace = true;
3427 args.discard_other_project = true;
3428
3429 let foreign_task = DumpTask {
3430 id: "foreign-dump-id".into(),
3431 status: DumpStatus::InProgress,
3432 error_message: None,
3433 created_at: None,
3434 };
3435
3436 let client = MockDspClient::new()
3437 .with_resolve_project(Ok(make_project_ref()))
3438 .with_create_exists_other_project("foreign-dump-id", foreign_iri())
3439 .with_status_sequence([Ok(foreign_task)]);
3440
3441 let mut renderer = RecordingRenderer::new();
3442 let mut reporter = RecordingProgressReporter::new();
3443
3444 let err = run_impl(
3445 &args,
3446 &cfg,
3447 &client,
3448 &mut renderer,
3449 &mut reporter,
3450 Some("tok".to_string()),
3451 &no_op_sleeper(),
3452 fixed_now(),
3453 None,
3454 dir.path(),
3455 )
3456 .unwrap_err();
3457
3458 assert!(
3459 matches!(err, Diagnostic::Conflict(_)),
3460 "foreign InProgress must yield Conflict, got {err:?}"
3461 );
3462 let msg = err.to_string();
3463 assert!(msg.contains("in progress"), "message must mention in progress: {msg}");
3464 assert!(msg.contains(foreign_iri()), "message must name the foreign IRI: {msg}");
3465 let log = client.call_log();
3467 assert_eq!(
3468 log[2],
3469 CallRecord::Status(foreign_iri().to_string()),
3470 "status must use FOREIGN iri"
3471 );
3472 assert_eq!(
3473 *client.delete_calls.borrow(),
3474 0,
3475 "delete must not be called for in-progress foreign dump"
3476 );
3477 }
3478
3479 #[test]
3480 fn delete_exists_for_other_project_is_noop_no_status_delete_calls() {
3481 let dir = TempDir::new().unwrap();
3484 let (mut args, cfg) = make_args(&dir);
3485 args.delete = true;
3486
3487 let client = MockDspClient::new()
3488 .with_resolve_project(Ok(make_project_ref()))
3489 .with_create_exists_other_project("foreign-dump-id", foreign_iri());
3490
3491 let mut renderer = RecordingRenderer::new();
3492 let mut reporter = RecordingProgressReporter::new();
3493
3494 run_impl(
3495 &args,
3496 &cfg,
3497 &client,
3498 &mut renderer,
3499 &mut reporter,
3500 Some("tok".to_string()),
3501 &no_op_sleeper(),
3502 fixed_now(),
3503 None,
3504 dir.path(),
3505 )
3506 .unwrap(); assert!(renderer.dump_outcome.is_none());
3510 let del_outcome = renderer
3512 .dump_deleted_outcome
3513 .expect("project_dump_deleted must have been called");
3514 assert!(!del_outcome.deleted, "deleted must be false for foreign-slot no-op");
3515 let note = del_outcome.note.expect("note must be set for foreign-slot case");
3516 assert!(note.contains(foreign_iri()), "note must name the foreign project IRI: {note}");
3517 let log = client.call_log();
3519 assert_eq!(log[0], CallRecord::Resolve);
3520 assert_eq!(
3521 log[1],
3522 CallRecord::Create { project_iri: "http://rdfh.ch/projects/0001".to_string() }
3523 );
3524 assert_eq!(log.len(), 2, "must be exactly 2 calls (no status/delete)");
3525 assert_eq!(
3526 *client.delete_calls.borrow(),
3527 0,
3528 "delete must not be called for foreign-slot no-op"
3529 );
3530 }
3531
3532 const LIST_SERVER: &str = "https://api.test.dasch.swiss";
3537
3538 fn make_list_args(filter: Option<&str>) -> ProjectListArgs {
3539 ProjectListArgs {
3540 server: Some(LIST_SERVER.to_string()),
3541 filter: filter.map(str::to_string),
3542 format: FormatArgs {
3543 format: Format::Prose,
3544 json: false,
3545 lines: false,
3546 columns: None,
3547 no_header: false,
3548 header_only: false,
3549 },
3550 }
3551 }
3552
3553 fn make_list_cfg() -> Config {
3554 Config { server: LIST_SERVER.to_string() }
3555 }
3556
3557 fn make_project(shortcode: &str, shortname: &str, longname: Option<&str>) -> Project {
3558 Project {
3559 iri: format!("http://rdfh.ch/projects/{shortcode}"),
3560 shortcode: shortcode.to_string(),
3561 shortname: shortname.to_string(),
3562 longname: longname.map(str::to_string),
3563 data_models: 2,
3564 }
3565 }
3566
3567 fn two_project_list() -> Vec<Project> {
3568 vec![
3569 make_project("0002", "images", None),
3570 make_project("0001", "anything", Some("Anything Project")),
3571 ]
3572 }
3573
3574 #[test]
3577 fn list_anonymous_no_token_no_cache() {
3578 let dir = TempDir::new().unwrap();
3579 let cache_path = dir.path().join("auth.toml");
3580 let args = make_list_args(None);
3581 let cfg = make_list_cfg();
3582
3583 let client = MockDspClient::new().with_list_projects_result(Ok(two_project_list()));
3584 let mut renderer = RecordingRenderer::new();
3585
3586 run_list_impl(&args, &cfg, &client, &mut renderer, None, Some(&cache_path)).expect("must succeed anonymously");
3587
3588 let meta = renderer.projects_meta.unwrap();
3589 assert_eq!(meta.auth_state, "anonymous");
3590
3591 let recorded_token = client.list_projects_token();
3592 assert_eq!(
3593 recorded_token, None,
3594 "must call list_projects with token=None when no credentials"
3595 );
3596
3597 let (items, total, filter) = renderer.projects_view.unwrap();
3598 assert_eq!(total, 2);
3599 assert_eq!(items.len(), 2);
3600 assert!(filter.is_none());
3601 }
3602
3603 #[test]
3606 fn list_corrupt_cache_falls_back_to_anonymous() {
3607 let dir = TempDir::new().unwrap();
3608 let cache_path = dir.path().join("auth.toml");
3610 std::fs::write(&cache_path, b"NOT VALID TOML }{").unwrap();
3611
3612 let args = make_list_args(None);
3613 let cfg = make_list_cfg();
3614
3615 let client = MockDspClient::new().with_list_projects_result(Ok(two_project_list()));
3616 let mut renderer = RecordingRenderer::new();
3617
3618 run_list_impl(&args, &cfg, &client, &mut renderer, None, Some(&cache_path))
3620 .expect("corrupt cache must not cause an error for list (auth-optional)");
3621
3622 let meta = renderer.projects_meta.unwrap();
3623 assert_eq!(meta.auth_state, "anonymous");
3624 assert_eq!(client.list_projects_token(), None);
3625 }
3626
3627 #[test]
3630 fn list_env_token_authenticated_via_dsp_token() {
3631 let dir = TempDir::new().unwrap();
3632 let cache_path = dir.path().join("auth.toml");
3633 let args = make_list_args(None);
3634 let cfg = make_list_cfg();
3635
3636 let client = MockDspClient::new().with_list_projects_result(Ok(two_project_list()));
3637 let mut renderer = RecordingRenderer::new();
3638
3639 run_list_impl(
3640 &args,
3641 &cfg,
3642 &client,
3643 &mut renderer,
3644 Some("my-env-token".to_string()),
3645 Some(&cache_path),
3646 )
3647 .expect("must succeed with env token");
3648
3649 let meta = renderer.projects_meta.unwrap();
3650 assert_eq!(meta.auth_state, "authenticated via DSP_TOKEN");
3651
3652 let recorded_token = client.list_projects_token();
3654 assert_eq!(
3655 recorded_token,
3656 Some("my-env-token".to_string()),
3657 "token must be forwarded to list_projects"
3658 );
3659 }
3660
3661 #[test]
3663 fn list_cache_token_with_user() {
3664 let dir = TempDir::new().unwrap();
3665 let cache_path = dir.path().join("auth.toml");
3666
3667 let mut cache = AuthCache::default();
3668 cache.set_entry(
3669 LIST_SERVER,
3670 ServerEntry {
3671 token: "cache-token-xyz".to_string(),
3672 user: Some("alice@example.com".to_string()),
3673 acquired_at: None,
3674 expires_at: None,
3675 },
3676 );
3677 cache.save_to(&cache_path).unwrap();
3678
3679 let args = make_list_args(None);
3680 let cfg = make_list_cfg();
3681
3682 let client = MockDspClient::new().with_list_projects_result(Ok(two_project_list()));
3683 let mut renderer = RecordingRenderer::new();
3684
3685 run_list_impl(&args, &cfg, &client, &mut renderer, None, Some(&cache_path))
3686 .expect("must succeed with cache token");
3687
3688 let meta = renderer.projects_meta.unwrap();
3689 assert_eq!(meta.auth_state, "authenticated as alice@example.com");
3690
3691 let recorded_token = client.list_projects_token();
3693 assert_eq!(
3694 recorded_token,
3695 Some("cache-token-xyz".to_string()),
3696 "cache token must be forwarded to list_projects"
3697 );
3698 }
3699
3700 #[test]
3703 fn list_filter_matches_subset_case_insensitively() {
3704 let dir = TempDir::new().unwrap();
3705 let cache_path = dir.path().join("auth.toml");
3706 let args = make_list_args(Some("ANYTH"));
3708 let cfg = make_list_cfg();
3709
3710 let client = MockDspClient::new().with_list_projects_result(Ok(two_project_list()));
3711 let mut renderer = RecordingRenderer::new();
3712
3713 run_list_impl(&args, &cfg, &client, &mut renderer, None, Some(&cache_path))
3714 .expect("filter must not cause an error");
3715
3716 let (items, total, filter) = renderer.projects_view.unwrap();
3717 assert_eq!(total, 2, "total must be pre-filter count");
3718 assert_eq!(items.len(), 1, "only one project matches 'ANYTH'");
3719 assert_eq!(items[0].shortname, "anything");
3720 assert_eq!(filter.as_deref(), Some("ANYTH"));
3721 }
3722
3723 #[test]
3725 fn list_filter_no_match_returns_empty_items() {
3726 let dir = TempDir::new().unwrap();
3727 let cache_path = dir.path().join("auth.toml");
3728 let args = make_list_args(Some("zzz-no-match-zzz"));
3729 let cfg = make_list_cfg();
3730
3731 let client = MockDspClient::new().with_list_projects_result(Ok(two_project_list()));
3732 let mut renderer = RecordingRenderer::new();
3733
3734 run_list_impl(&args, &cfg, &client, &mut renderer, None, Some(&cache_path))
3735 .expect("no-match filter must not be an error");
3736
3737 let (items, total, _) = renderer.projects_view.unwrap();
3738 assert_eq!(total, 2, "total must still show pre-filter count");
3739 assert!(items.is_empty(), "items must be empty when filter matches nothing");
3740 }
3741
3742 #[test]
3744 fn list_results_sorted_by_shortcode_ascending() {
3745 let dir = TempDir::new().unwrap();
3746 let cache_path = dir.path().join("auth.toml");
3747 let args = make_list_args(None);
3748 let cfg = make_list_cfg();
3749
3750 let unsorted = vec![
3752 make_project("0003", "proj-c", None),
3753 make_project("0001", "proj-a", None),
3754 make_project("0002", "proj-b", None),
3755 ];
3756
3757 let client = MockDspClient::new().with_list_projects_result(Ok(unsorted));
3758 let mut renderer = RecordingRenderer::new();
3759
3760 run_list_impl(&args, &cfg, &client, &mut renderer, None, Some(&cache_path)).expect("sort must not error");
3761
3762 let (items, _, _) = renderer.projects_view.unwrap();
3763 let shortcodes: Vec<&str> = items.iter().map(|p| p.shortcode.as_str()).collect();
3764 assert_eq!(shortcodes, vec!["0001", "0002", "0003"]);
3765 }
3766
3767 #[test]
3769 fn list_filter_matches_longname_case_insensitively() {
3770 let dir = TempDir::new().unwrap();
3771 let cache_path = dir.path().join("auth.toml");
3772 let args = make_list_args(Some("ANYTHING PROJECT"));
3774 let cfg = make_list_cfg();
3775
3776 let client = MockDspClient::new().with_list_projects_result(Ok(two_project_list()));
3778 let mut renderer = RecordingRenderer::new();
3779
3780 run_list_impl(&args, &cfg, &client, &mut renderer, None, Some(&cache_path))
3781 .expect("longname filter must not error");
3782
3783 let (items, total, _) = renderer.projects_view.unwrap();
3784 assert_eq!(total, 2);
3785 assert_eq!(items.len(), 1);
3786 assert_eq!(items[0].shortname, "anything");
3787 }
3788
3789 #[test]
3791 fn list_filter_matches_shortcode() {
3792 let dir = TempDir::new().unwrap();
3793 let cache_path = dir.path().join("auth.toml");
3794 let args = make_list_args(Some("0002"));
3795 let cfg = make_list_cfg();
3796
3797 let client = MockDspClient::new().with_list_projects_result(Ok(two_project_list()));
3798 let mut renderer = RecordingRenderer::new();
3799
3800 run_list_impl(&args, &cfg, &client, &mut renderer, None, Some(&cache_path))
3801 .expect("shortcode filter must not error");
3802
3803 let (items, total, _) = renderer.projects_view.unwrap();
3804 assert_eq!(total, 2);
3805 assert_eq!(items.len(), 1);
3806 assert_eq!(items[0].shortcode, "0002");
3807 }
3808
3809 #[test]
3812 fn config_resolve_none_returns_usage_error() {
3813 let err = crate::config::Config::resolve(None, false).unwrap_err();
3814 assert!(
3815 matches!(err, Diagnostic::Usage(_)),
3816 "expected Usage diagnostic for missing server, got {err:?}"
3817 );
3818 let msg = err.to_string();
3819 assert!(msg.contains("--server") || msg.contains("DSP_SERVER"), "{msg}");
3820 }
3821
3822 #[test]
3825 fn list_token_assertion_is_real() {
3826 let dir = TempDir::new().unwrap();
3827 let cache_path = dir.path().join("auth.toml");
3828 let args = make_list_args(None);
3829 let cfg = make_list_cfg();
3830
3831 let client = MockDspClient::new().with_list_projects_result(Ok(two_project_list()));
3832 let mut renderer = RecordingRenderer::new();
3833
3834 run_list_impl(
3835 &args,
3836 &cfg,
3837 &client,
3838 &mut renderer,
3839 Some("my-env-token".to_string()),
3840 Some(&cache_path),
3841 )
3842 .unwrap();
3843
3844 let recorded = client.list_projects_token();
3845 assert_eq!(recorded, Some("my-env-token".to_string()));
3847 assert_ne!(
3848 recorded,
3849 Some("wrong-token".to_string()),
3850 "token assertion must be real: wrong token should not match"
3851 );
3852 }
3853
3854 const DESCRIBE_SERVER: &str = "https://api.test.dasch.swiss";
3858
3859 fn make_describe_args(project: Option<&str>) -> ProjectDescribeArgs {
3860 ProjectDescribeArgs {
3861 server: Some(DESCRIBE_SERVER.to_string()),
3862 project: project.map(str::to_string),
3863 format: FormatArgs {
3864 format: Format::Prose,
3865 json: false,
3866 lines: false,
3867 columns: None,
3868 no_header: false,
3869 header_only: false,
3870 },
3871 }
3872 }
3873
3874 fn make_describe_cfg() -> Config {
3875 Config { server: DESCRIBE_SERVER.to_string() }
3876 }
3877
3878 fn make_project_detail() -> ProjectDetail {
3880 ProjectDetail {
3881 iri: "http://rdfh.ch/projects/yTerZGyxjZVqFMNNKXCDPF".to_string(),
3882 shortcode: "0801".to_string(),
3883 shortname: "beol".to_string(),
3884 longname: Some("Bernoulli-Euler Online".to_string()),
3885 description: vec![ProjectDescription {
3886 value: "A project about Bernoulli and Euler.".to_string(),
3887 language: Some("en".to_string()),
3888 }],
3889 keywords: vec!["Bernoulli".to_string(), "Euler".to_string()],
3890 data_models: vec![
3891 DataModelSummary {
3892 name: "beol".to_string(),
3893 iri: "http://api.dasch.swiss/ontology/0801/beol/v2".to_string(),
3894 },
3895 DataModelSummary {
3896 name: "biblio".to_string(),
3897 iri: "http://api.dasch.swiss/ontology/0801/biblio/v2".to_string(),
3898 },
3899 ],
3900 }
3901 }
3902
3903 #[test]
3905 fn describe_success_records_detail_and_meta() {
3906 let dir = TempDir::new().unwrap();
3907 let cache_path = dir.path().join("auth.toml");
3908 let args = make_describe_args(Some("0801"));
3909 let cfg = make_describe_cfg();
3910 let detail = make_project_detail();
3911
3912 let client = MockDspClient::new().with_describe_project_result(Ok(detail.clone()));
3913 let mut renderer = RecordingRenderer::new();
3914
3915 run_describe_impl(&args, &cfg, &client, &mut renderer, None, Some(&cache_path)).expect("describe must succeed");
3916
3917 let recorded_detail = renderer.describe_detail.unwrap();
3918 assert_eq!(recorded_detail, detail, "renderer must receive the exact ProjectDetail");
3919
3920 let meta = renderer.describe_meta.unwrap();
3921 assert_eq!(meta.server_label, DESCRIBE_SERVER);
3922 assert_eq!(meta.auth_state, "anonymous");
3923 assert!(meta.filter_warning.is_none());
3924 }
3925
3926 #[test]
3928 fn describe_forwards_project_and_token_to_client() {
3929 let dir = TempDir::new().unwrap();
3930 let cache_path = dir.path().join("auth.toml");
3931 let args = make_describe_args(Some("0801"));
3932 let cfg = make_describe_cfg();
3933
3934 let client = MockDspClient::new().with_describe_project_result(Ok(make_project_detail()));
3935 let mut renderer = RecordingRenderer::new();
3936
3937 run_describe_impl(
3938 &args,
3939 &cfg,
3940 &client,
3941 &mut renderer,
3942 Some("my-env-token".to_string()),
3943 Some(&cache_path),
3944 )
3945 .expect("describe must succeed");
3946
3947 let (project_arg, token_arg) = client.describe_project_call();
3948 assert_eq!(project_arg, "0801", "project argument must be forwarded");
3949 assert_eq!(
3950 token_arg,
3951 Some("my-env-token".to_string()),
3952 "env token must be forwarded to describe_project"
3953 );
3954 }
3955
3956 #[test]
3958 fn describe_not_found_propagates_error() {
3959 let dir = TempDir::new().unwrap();
3960 let cache_path = dir.path().join("auth.toml");
3961 let args = make_describe_args(Some("9999"));
3962 let cfg = make_describe_cfg();
3963
3964 let client = MockDspClient::new()
3965 .with_describe_project_result(Err(Diagnostic::NotFound("project '9999' not found".to_string())));
3966 let mut renderer = RecordingRenderer::new();
3967
3968 let err = run_describe_impl(&args, &cfg, &client, &mut renderer, None, Some(&cache_path)).unwrap_err();
3969
3970 assert!(matches!(err, Diagnostic::NotFound(_)), "expected NotFound, got {err:?}");
3971 }
3972
3973 #[test]
3976 fn describe_missing_project_returns_usage_error() {
3977 let dir = TempDir::new().unwrap();
3978 let cache_path = dir.path().join("auth.toml");
3979 let args = make_describe_args(None); let cfg = make_describe_cfg();
3981
3982 let client = MockDspClient::new();
3984 let mut renderer = RecordingRenderer::new();
3985
3986 let err = run_describe_impl(&args, &cfg, &client, &mut renderer, None, Some(&cache_path)).unwrap_err();
3987
3988 assert!(
3989 matches!(err, Diagnostic::Usage(_)),
3990 "expected Usage diagnostic for missing --project, got {err:?}"
3991 );
3992 let msg = err.to_string();
3993 assert!(msg.contains("--project"), "--project must appear in the usage message: {msg}");
3994
3995 assert!(
3997 renderer.describe_detail.is_none(),
3998 "renderer must not be called when --project is missing"
3999 );
4000 assert!(
4002 !client.describe_project_was_called(),
4003 "client.describe_project must not be called when --project is missing"
4004 );
4005 }
4006
4007 #[test]
4009 fn describe_anonymous_no_token_no_cache() {
4010 let dir = TempDir::new().unwrap();
4011 let cache_path = dir.path().join("auth.toml");
4012 let args = make_describe_args(Some("0801"));
4013 let cfg = make_describe_cfg();
4014
4015 let client = MockDspClient::new().with_describe_project_result(Ok(make_project_detail()));
4016 let mut renderer = RecordingRenderer::new();
4017
4018 run_describe_impl(&args, &cfg, &client, &mut renderer, None, Some(&cache_path))
4019 .expect("must succeed anonymously");
4020
4021 let meta = renderer.describe_meta.unwrap();
4022 assert_eq!(meta.auth_state, "anonymous");
4023
4024 let (_, token_arg) = client.describe_project_call();
4025 assert_eq!(token_arg, None, "no token must be forwarded when anonymous");
4026 }
4027
4028 #[test]
4030 fn describe_env_token_authenticated_via_dsp_token() {
4031 let dir = TempDir::new().unwrap();
4032 let cache_path = dir.path().join("auth.toml");
4033 let args = make_describe_args(Some("0801"));
4034 let cfg = make_describe_cfg();
4035
4036 let client = MockDspClient::new().with_describe_project_result(Ok(make_project_detail()));
4037 let mut renderer = RecordingRenderer::new();
4038
4039 run_describe_impl(
4040 &args,
4041 &cfg,
4042 &client,
4043 &mut renderer,
4044 Some("env-token-xyz".to_string()),
4045 Some(&cache_path),
4046 )
4047 .expect("must succeed with env token");
4048
4049 let meta = renderer.describe_meta.unwrap();
4050 assert_eq!(meta.auth_state, "authenticated via DSP_TOKEN");
4051
4052 let (_, token_arg) = client.describe_project_call();
4053 assert_eq!(
4054 token_arg,
4055 Some("env-token-xyz".to_string()),
4056 "env token must be forwarded to describe_project"
4057 );
4058 }
4059
4060 #[test]
4062 fn describe_cache_token_with_user() {
4063 let dir = TempDir::new().unwrap();
4064 let cache_path = dir.path().join("auth.toml");
4065
4066 let mut cache = AuthCache::default();
4067 cache.set_entry(
4068 DESCRIBE_SERVER,
4069 ServerEntry {
4070 token: "cache-token-abc".to_string(),
4071 user: Some("bob@example.com".to_string()),
4072 acquired_at: None,
4073 expires_at: None,
4074 },
4075 );
4076 cache.save_to(&cache_path).unwrap();
4077
4078 let args = make_describe_args(Some("0801"));
4079 let cfg = make_describe_cfg();
4080
4081 let client = MockDspClient::new().with_describe_project_result(Ok(make_project_detail()));
4082 let mut renderer = RecordingRenderer::new();
4083
4084 run_describe_impl(&args, &cfg, &client, &mut renderer, None, Some(&cache_path))
4085 .expect("must succeed with cache token");
4086
4087 let meta = renderer.describe_meta.unwrap();
4088 assert_eq!(meta.auth_state, "authenticated as bob@example.com");
4089
4090 let (_, token_arg) = client.describe_project_call();
4091 assert_eq!(
4092 token_arg,
4093 Some("cache-token-abc".to_string()),
4094 "cache token must be forwarded to describe_project"
4095 );
4096 }
4097
4098 #[test]
4101 fn describe_corrupt_cache_falls_back_to_anonymous() {
4102 let dir = TempDir::new().unwrap();
4103 let cache_path = dir.path().join("auth.toml");
4104 std::fs::write(&cache_path, b"NOT VALID TOML }{").unwrap();
4105
4106 let args = make_describe_args(Some("0801"));
4107 let cfg = make_describe_cfg();
4108
4109 let client = MockDspClient::new().with_describe_project_result(Ok(make_project_detail()));
4110 let mut renderer = RecordingRenderer::new();
4111
4112 run_describe_impl(&args, &cfg, &client, &mut renderer, None, Some(&cache_path))
4114 .expect("corrupt cache must not cause an error for describe (auth-optional)");
4115
4116 let meta = renderer.describe_meta.unwrap();
4117 assert_eq!(meta.auth_state, "anonymous");
4118
4119 let (_, token_arg) = client.describe_project_call();
4120 assert_eq!(token_arg, None, "no token must be forwarded when anonymous");
4121 }
4122}