use std::io::Write as _;
use std::path::{Path, PathBuf};
use std::time::Duration;
use chrono::{DateTime, Utc};
use crate::cli::{ProjectDescribeArgs, ProjectDumpArgs, ProjectListArgs};
use crate::client::DspClient;
use crate::config::{AuthCache, Config, resolve_token};
use crate::diagnostic::Diagnostic;
use crate::model::{CreateDumpOutcome, DumpStatus};
use crate::render::progress::ProgressReporter;
use crate::render::{
DumpDeleteOutcome, DumpEvent, DumpOutcome, MetaContext, ProjectListView, Renderer,
};
use crate::actions::auth_state::read_auth_state;
pub fn list(
args: &ProjectListArgs,
cfg: &Config,
client: &dyn DspClient,
renderer: &mut dyn Renderer,
) -> Result<(), Diagnostic> {
let env_token = std::env::var("DSP_TOKEN").ok();
run_list_impl(args, cfg, client, renderer, env_token, None)
}
fn run_list_impl(
args: &ProjectListArgs,
cfg: &Config,
client: &dyn DspClient,
renderer: &mut dyn Renderer,
env_token: Option<String>,
cache_path: Option<&Path>,
) -> Result<(), Diagnostic> {
let cache_result = match cache_path {
Some(p) => AuthCache::load_from(p),
None => AuthCache::load(),
};
let cache = match cache_result {
Ok(c) => c,
Err(e) => {
tracing::warn!(
error = %e,
"auth cache load failed; falling back to anonymous for project list"
);
AuthCache::default()
}
};
let resolved = resolve_token(env_token, &cache, &cfg.server);
let token = resolved.as_ref().map(|r| r.token.as_str());
let auth_state = read_auth_state(resolved.as_ref(), &cache, &cfg.server);
let mut projects = client.list_projects(&cfg.server, token)?;
let total = projects.len();
if let Some(ref f) = args.filter {
let lower = f.to_lowercase();
projects.retain(|p| {
p.shortcode.to_lowercase().contains(&lower)
|| p.shortname.to_lowercase().contains(&lower)
|| p.longname
.as_deref()
.unwrap_or("")
.to_lowercase()
.contains(&lower)
});
}
projects.sort_by(|a, b| a.shortcode.cmp(&b.shortcode));
let view = ProjectListView {
items: projects,
total,
filter: args.filter.clone(),
};
let meta = MetaContext {
server_label: cfg.server.clone(),
auth_state,
filter_warning: None,
};
renderer.projects(&view, &meta)
}
pub fn describe(
args: &ProjectDescribeArgs,
cfg: &Config,
client: &dyn DspClient,
renderer: &mut dyn Renderer,
) -> Result<(), Diagnostic> {
let env_token = std::env::var("DSP_TOKEN").ok();
run_describe_impl(args, cfg, client, renderer, env_token, None)
}
fn run_describe_impl(
args: &ProjectDescribeArgs,
cfg: &Config,
client: &dyn DspClient,
renderer: &mut dyn Renderer,
env_token: Option<String>,
cache_path: Option<&Path>,
) -> Result<(), Diagnostic> {
let project = args.project.as_deref().ok_or_else(|| {
Diagnostic::Usage("--project <shortcode|shortname|IRI> is required".to_string())
})?;
let cache_result = match cache_path {
Some(p) => AuthCache::load_from(p),
None => AuthCache::load(),
};
let cache = match cache_result {
Ok(c) => c,
Err(e) => {
tracing::warn!(
error = %e,
"auth cache load failed; falling back to anonymous for project describe"
);
AuthCache::default()
}
};
let resolved = resolve_token(env_token, &cache, &cfg.server);
let token = resolved.as_ref().map(|r| r.token.as_str());
let auth_state = read_auth_state(resolved.as_ref(), &cache, &cfg.server);
let detail = client.describe_project(&cfg.server, project, token)?;
let meta = MetaContext {
server_label: cfg.server.clone(),
auth_state,
filter_warning: None,
};
renderer.project_describe(&detail, &meta)
}
pub fn dump(
args: &ProjectDumpArgs,
cfg: &Config,
client: &dyn DspClient,
renderer: &mut dyn Renderer,
reporter: &mut dyn ProgressReporter,
) -> Result<(), Diagnostic> {
let env_token = std::env::var("DSP_TOKEN").ok();
let cwd = std::env::current_dir()
.map_err(|e| Diagnostic::Io(format!("could not determine current directory: {e}")))?;
run_impl(
args,
cfg,
client,
renderer,
reporter,
env_token,
&|d| std::thread::sleep(d),
Utc::now(),
None,
&cwd,
)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum DumpMode {
Default,
Replace,
Delete,
}
#[allow(clippy::too_many_arguments)]
fn run_impl(
args: &ProjectDumpArgs,
cfg: &Config,
client: &dyn DspClient,
renderer: &mut dyn Renderer,
reporter: &mut dyn ProgressReporter,
env_token: Option<String>,
sleeper: &dyn Fn(Duration),
now: DateTime<Utc>,
cache_path: Option<&Path>,
cwd: &Path,
) -> Result<(), Diagnostic> {
let env_token_would_win = env_token
.as_deref()
.map(str::trim)
.map(|s| !s.is_empty())
.unwrap_or(false);
let cache_result = match cache_path {
Some(p) => AuthCache::load_from(p),
None => AuthCache::load(),
};
let cache = match cache_result {
Ok(c) => c,
Err(e) if env_token_would_win => {
tracing::warn!(
error = %e,
"auth cache load failed; DSP_TOKEN is set, falling through to env token"
);
AuthCache::default()
}
Err(e) => return Err(e),
};
let resolved = resolve_token(env_token, &cache, &cfg.server).ok_or_else(|| {
Diagnostic::AuthRequired(
"dsp vre project dump requires a system-administrator token; \
run `dsp auth login --server <s>` or set DSP_TOKEN"
.to_string(),
)
})?;
let token = resolved.token.clone();
let project = args.project.as_deref().ok_or_else(|| {
Diagnostic::Usage("--project <shortcode|shortname|IRI> is required".to_string())
})?;
let mode = if args.delete {
DumpMode::Delete
} else if args.replace {
DumpMode::Replace
} else {
DumpMode::Default
};
let explicit_output = if mode != DumpMode::Delete {
args.output.clone()
} else {
None
};
if let Some(ref path) = explicit_output
&& path.exists()
&& !args.force
{
return Err(Diagnostic::Usage(format!(
"refusing to overwrite {path}; pass --force",
path = path.display()
)));
}
let proj = client.resolve_project(&cfg.server, project)?;
let output_path: Option<PathBuf> = if mode != DumpMode::Delete {
let p = match explicit_output {
Some(p) => p,
None => {
let default = default_output_path(cwd, &proj.shortcode, now);
if default.exists() && !args.force {
return Err(Diagnostic::Usage(format!(
"refusing to overwrite {path}; pass --force",
path = default.display()
)));
}
default
}
};
Some(p)
} else {
None
};
let create_outcome =
client.create_project_dump(&cfg.server, &proj.iri, args.skip_assets, &token)?;
let meta = MetaContext {
server_label: cfg.server.clone(),
auth_state: read_auth_state(Some(&resolved), &cache, &cfg.server),
filter_warning: None,
};
match mode {
DumpMode::Default => handle_default(
create_outcome,
args,
cfg,
client,
renderer,
reporter,
&token,
&proj.iri,
output_path.ok_or_else(|| {
Diagnostic::Internal("output_path unexpectedly None in Default mode".into())
})?,
&meta,
sleeper,
),
DumpMode::Replace => handle_replace(
create_outcome,
args,
cfg,
client,
renderer,
reporter,
&token,
&proj.iri,
args.skip_assets,
output_path.ok_or_else(|| {
Diagnostic::Internal("output_path unexpectedly None in Replace mode".into())
})?,
&meta,
sleeper,
),
DumpMode::Delete => handle_delete(
create_outcome,
cfg,
client,
renderer,
reporter,
&token,
&proj.iri,
&meta,
),
}
}
fn recreated_dump_ids(
outcome: CreateDumpOutcome,
) -> Result<(String, Option<chrono::DateTime<chrono::Utc>>), Diagnostic> {
match outcome {
CreateDumpOutcome::Created(task2) => Ok((task2.id, task2.created_at)),
CreateDumpOutcome::Exists { .. } => Err(Diagnostic::Conflict(
"the dump was recreated before it could be replaced; try again".into(),
)),
CreateDumpOutcome::ExistsForOtherProject {
project_iri: racer, ..
} => Err(Diagnostic::Conflict(format!(
"the dump slot was claimed by another project ({racer}) \
before this one could be created; try again"
))),
}
}
#[allow(clippy::too_many_arguments)]
fn handle_default(
create_outcome: CreateDumpOutcome,
args: &ProjectDumpArgs,
cfg: &Config,
client: &dyn DspClient,
renderer: &mut dyn Renderer,
reporter: &mut dyn ProgressReporter,
token: &str,
project_iri: &str,
output_path: PathBuf,
meta: &MetaContext,
sleeper: &dyn Fn(Duration),
) -> Result<(), Diagnostic> {
match create_outcome {
CreateDumpOutcome::Created(task) => {
reporter.report(&DumpEvent::Triggered {
id: task.id.clone(),
})?;
let created_at = task.created_at;
let id = task.id;
poll_until_done(
client,
cfg,
reporter,
token,
project_iri,
&id,
args.timeout,
sleeper,
)?;
reporter.report(&DumpEvent::Downloading)?;
let bytes =
stream_dump_to_path(client, &cfg.server, project_iri, &id, token, &output_path)?;
let cleaned_up =
run_cleanup(args.cleanup, client, &cfg.server, project_iri, &id, token);
reporter.report(&DumpEvent::Done { bytes })?;
renderer.project_dump(
&DumpOutcome {
path: output_path,
bytes,
cleaned_up,
reused: false,
created_at,
},
meta,
)
}
CreateDumpOutcome::ExistsForOtherProject {
project_iri: foreign_iri,
..
} => Err(Diagnostic::Conflict(format!(
"no dump exists for the requested project; the server holds a single \
dump and it currently belongs to a different project ({foreign_iri}). Re-run \
with --replace --discard-other-project to discard that dump and create this \
project's, or wait for it to be removed."
))),
CreateDumpOutcome::Exists { id } => {
let status_task =
client.get_project_dump_status(&cfg.server, project_iri, &id, token)?;
match status_task.status {
DumpStatus::Failed => Err(Diagnostic::Conflict(format!(
"the existing dump failed: {}; re-run with --replace to discard \
and create a fresh one, or --delete to remove it",
status_task.error_message.unwrap_or_default()
))),
DumpStatus::Completed => {
reporter.report(&DumpEvent::Adopting { id: id.clone() })?;
reporter.report(&DumpEvent::Downloading)?;
let bytes = stream_dump_to_path(
client,
&cfg.server,
project_iri,
&id,
token,
&output_path,
)?;
let cleaned_up =
run_cleanup(args.cleanup, client, &cfg.server, project_iri, &id, token);
reporter.report(&DumpEvent::Done { bytes })?;
renderer.project_dump(
&DumpOutcome {
path: output_path,
bytes,
cleaned_up,
reused: true,
created_at: status_task.created_at,
},
meta,
)
}
DumpStatus::InProgress => {
reporter.report(&DumpEvent::Adopting { id: id.clone() })?;
let created_at = status_task.created_at;
poll_until_done(
client,
cfg,
reporter,
token,
project_iri,
&id,
args.timeout,
sleeper,
)?;
reporter.report(&DumpEvent::Downloading)?;
let bytes = stream_dump_to_path(
client,
&cfg.server,
project_iri,
&id,
token,
&output_path,
)?;
let cleaned_up =
run_cleanup(args.cleanup, client, &cfg.server, project_iri, &id, token);
reporter.report(&DumpEvent::Done { bytes })?;
renderer.project_dump(
&DumpOutcome {
path: output_path,
bytes,
cleaned_up,
reused: true,
created_at,
},
meta,
)
}
}
}
}
}
#[allow(clippy::too_many_arguments)]
fn handle_replace(
create_outcome: CreateDumpOutcome,
args: &ProjectDumpArgs,
cfg: &Config,
client: &dyn DspClient,
renderer: &mut dyn Renderer,
reporter: &mut dyn ProgressReporter,
token: &str,
project_iri: &str,
skip_assets: bool,
output_path: PathBuf,
meta: &MetaContext,
sleeper: &dyn Fn(Duration),
) -> Result<(), Diagnostic> {
let (id, created_at) = match create_outcome {
CreateDumpOutcome::Created(task) => {
reporter.report(&DumpEvent::Triggered {
id: task.id.clone(),
})?;
let created_at = task.created_at;
let id = task.id;
poll_until_done(
client,
cfg,
reporter,
token,
project_iri,
&id,
args.timeout,
sleeper,
)?;
reporter.report(&DumpEvent::Downloading)?;
let bytes =
stream_dump_to_path(client, &cfg.server, project_iri, &id, token, &output_path)?;
let cleaned_up =
run_cleanup(args.cleanup, client, &cfg.server, project_iri, &id, token);
reporter.report(&DumpEvent::Done { bytes })?;
return renderer.project_dump(
&DumpOutcome {
path: output_path,
bytes,
cleaned_up,
reused: false,
created_at,
},
meta,
);
}
CreateDumpOutcome::ExistsForOtherProject {
id: foreign_id,
project_iri: foreign_iri,
} => {
if !args.discard_other_project {
return Err(Diagnostic::Conflict(format!(
"the server's single dump slot is held by a different project \
({foreign_iri}); re-run with --replace --discard-other-project to discard that \
project's dump and create this one's"
)));
}
let foreign =
client.get_project_dump_status(&cfg.server, &foreign_iri, &foreign_id, token)?;
match foreign.status {
DumpStatus::InProgress => {
return Err(Diagnostic::Conflict(format!(
"a dump for a different project ({foreign_iri}) is currently in \
progress; it cannot be discarded until it finishes — wait and retry"
)));
}
DumpStatus::Completed | DumpStatus::Failed => {
reporter.report(&DumpEvent::DiscardingOtherProjectDump {
id: foreign_id.clone(),
project_iri: foreign_iri.clone(),
})?;
client.delete_project_dump(&cfg.server, &foreign_iri, &foreign_id, token)?;
let create2 =
client.create_project_dump(&cfg.server, project_iri, skip_assets, token)?;
recreated_dump_ids(create2)?
}
}
}
CreateDumpOutcome::Exists { id } => {
let status_task =
client.get_project_dump_status(&cfg.server, project_iri, &id, token)?;
match status_task.status {
DumpStatus::InProgress => {
return Err(Diagnostic::Conflict(
"a dump is already in progress; it cannot be replaced until it finishes"
.into(),
));
}
DumpStatus::Completed | DumpStatus::Failed => {
reporter.report(&DumpEvent::Deleting { id: id.clone() })?;
client.delete_project_dump(&cfg.server, project_iri, &id, token)?;
let create2 =
client.create_project_dump(&cfg.server, project_iri, skip_assets, token)?;
recreated_dump_ids(create2)?
}
}
}
};
reporter.report(&DumpEvent::Triggered { id: id.clone() })?;
poll_until_done(
client,
cfg,
reporter,
token,
project_iri,
&id,
args.timeout,
sleeper,
)?;
reporter.report(&DumpEvent::Downloading)?;
let bytes = stream_dump_to_path(client, &cfg.server, project_iri, &id, token, &output_path)?;
let cleaned_up = run_cleanup(args.cleanup, client, &cfg.server, project_iri, &id, token);
reporter.report(&DumpEvent::Done { bytes })?;
renderer.project_dump(
&DumpOutcome {
path: output_path,
bytes,
cleaned_up,
reused: false,
created_at,
},
meta,
)
}
#[allow(clippy::too_many_arguments)]
fn handle_delete(
create_outcome: CreateDumpOutcome,
cfg: &Config,
client: &dyn DspClient,
renderer: &mut dyn Renderer,
reporter: &mut dyn ProgressReporter,
token: &str,
project_iri: &str,
meta: &MetaContext,
) -> Result<(), Diagnostic> {
match create_outcome {
CreateDumpOutcome::ExistsForOtherProject {
project_iri: foreign_iri,
..
} => renderer.project_dump_deleted(
&DumpDeleteOutcome {
deleted: false,
note: Some(format!(
"no dump for the requested project to delete; the server's \
single dump slot is held by a different project ({foreign_iri})"
)),
},
meta,
),
CreateDumpOutcome::Exists { id } => {
let status_task =
client.get_project_dump_status(&cfg.server, project_iri, &id, token)?;
match status_task.status {
DumpStatus::InProgress => Err(Diagnostic::Conflict(
"the dump is in progress and cannot be deleted until it finishes".into(),
)),
DumpStatus::Completed | DumpStatus::Failed => {
reporter.report(&DumpEvent::Deleting { id: id.clone() })?;
client.delete_project_dump(&cfg.server, project_iri, &id, token)?;
renderer.project_dump_deleted(
&DumpDeleteOutcome {
deleted: true,
note: None,
},
meta,
)
}
}
}
CreateDumpOutcome::Created(task) => {
let note = format!(
"no dump existed to delete; a probe created an in-progress dump {} \
that will complete server-side",
task.id
);
reporter.report(&DumpEvent::ProbeCreated {
id: task.id.clone(),
})?;
tracing::warn!(
id = %task.id,
"delete mode: no existing dump found; probe created in-progress dump \
that will complete server-side"
);
renderer.project_dump_deleted(
&DumpDeleteOutcome {
deleted: false,
note: Some(note),
},
meta,
)
}
}
}
#[allow(clippy::too_many_arguments)]
fn poll_until_done(
client: &dyn DspClient,
cfg: &Config,
reporter: &mut dyn ProgressReporter,
token: &str,
project_iri: &str,
dump_id: &str,
timeout_secs: u64,
sleeper: &dyn Fn(Duration),
) -> Result<(), Diagnostic> {
const BASE: Duration = Duration::from_secs(1);
const CAP: Duration = Duration::from_secs(30);
let timeout = Duration::from_secs(timeout_secs);
let mut elapsed = Duration::ZERO;
let mut delay = BASE;
loop {
let t = client.get_project_dump_status(&cfg.server, project_iri, dump_id, token)?;
match t.status {
DumpStatus::Completed => return Ok(()),
DumpStatus::Failed => {
return Err(Diagnostic::ServerError(format!(
"server-side dump failed: {}",
t.error_message.unwrap_or_default()
)));
}
DumpStatus::InProgress => {
reporter.report(&DumpEvent::Polling {
elapsed_secs: elapsed.as_secs(),
status: DumpStatus::InProgress,
})?;
if elapsed + delay >= timeout {
return Err(Diagnostic::ServerError(format!(
"dump did not complete within {timeout_secs}s; \
the server-side dump may still be running"
)));
}
sleeper(delay);
elapsed += delay;
delay = (delay * 2).min(CAP);
}
}
}
}
fn run_cleanup(
cleanup: bool,
client: &dyn DspClient,
server: &str,
project_iri: &str,
dump_id: &str,
token: &str,
) -> bool {
if !cleanup {
return false;
}
match client.delete_project_dump(server, project_iri, dump_id, token) {
Ok(()) => true,
Err(e) => {
tracing::warn!(error = %e, "cleanup failed; dump not deleted from server");
false
}
}
}
fn stream_dump_to_path(
client: &dyn DspClient,
server: &str,
project_iri: &str,
dump_id: &str,
token: &str,
final_path: &Path,
) -> Result<u64, Diagnostic> {
let file_name = final_path.file_name().ok_or_else(|| {
Diagnostic::Usage(format!("invalid --output path: {}", final_path.display()))
})?;
let temp_path = {
let pid = std::process::id();
let name = format!("{}.{pid}.partial", file_name.to_string_lossy());
final_path
.parent()
.ok_or_else(|| {
Diagnostic::Io(format!(
"cannot determine parent directory of output path {}",
final_path.display()
))
})?
.join(&name)
};
let mut open_opts = std::fs::OpenOptions::new();
open_opts.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
open_opts.mode(0o600);
}
let mut file = open_opts.open(&temp_path).map_err(|e| {
Diagnostic::Io(format!(
"failed to create temp file {}: {e}",
temp_path.display()
))
})?;
let result = client.download_project_dump(server, project_iri, dump_id, token, &mut file);
let bytes = match result {
Err(e) => {
let _ = std::fs::remove_file(&temp_path);
return Err(e);
}
Ok(n) => n,
};
file.flush().map_err(|e| {
let _ = std::fs::remove_file(&temp_path);
Diagnostic::Io(format!(
"failed to flush temp file {}: {e}",
temp_path.display()
))
})?;
file.sync_all().map_err(|e| {
let _ = std::fs::remove_file(&temp_path);
Diagnostic::Io(format!(
"failed to sync temp file {}: {e}",
temp_path.display()
))
})?;
std::fs::rename(&temp_path, final_path).map_err(|e| {
let _ = std::fs::remove_file(&temp_path);
Diagnostic::Io(format!(
"failed to rename {} to {}: {e}",
temp_path.display(),
final_path.display()
))
})?;
Ok(bytes)
}
fn default_output_path(base: &Path, shortcode: &str, now: DateTime<Utc>) -> PathBuf {
base.join(format!("{shortcode}-{}.zip", now.format("%Y%m%dT%H%M%SZ")))
}
#[cfg(test)]
mod tests {
use std::cell::{Cell, RefCell};
use std::collections::VecDeque;
use std::path::PathBuf;
use std::time::Duration;
use chrono::{TimeZone, Utc};
use tempfile::TempDir;
use super::{default_output_path, run_describe_impl, run_impl, run_list_impl};
use crate::cli::{FormatArgs, ProjectDescribeArgs, ProjectDumpArgs, ProjectListArgs};
use crate::client::DspClient;
use crate::config::auth_cache::ServerEntry;
use crate::config::{AuthCache, Config};
use crate::diagnostic::Diagnostic;
use crate::model::{
CreateDumpOutcome, DataModelSummary, DumpStatus, DumpTask, Project, ProjectDescription,
ProjectDetail, ProjectRef, ProjectStatus,
};
use crate::render::auth::{
AuthLoginOutcome, AuthLogoutOutcome, AuthSetTokenOutcome, AuthStatusOutcome,
};
use crate::render::progress::ProgressReporter;
use crate::render::{
DumpDeleteOutcome, DumpEvent, DumpOutcome, Format, MetaContext, ProjectListView, Renderer,
};
#[derive(Debug, Clone, PartialEq)]
enum CallRecord {
Resolve,
Create {
project_iri: String,
},
Poll,
Download,
Delete(String),
Status(String),
ListProjects {
token: Option<String>,
},
}
struct MockDspClient {
resolve_result: Option<Result<ProjectRef, Diagnostic>>,
resolve_calls: RefCell<u32>,
create_result: Option<Result<CreateDumpOutcome, Diagnostic>>,
create_sequence: RefCell<VecDeque<Result<CreateDumpOutcome, Diagnostic>>>,
create_calls: RefCell<u32>,
create_skip_assets: Cell<Option<bool>>,
poll_sequence: RefCell<VecDeque<Result<DumpTask, Diagnostic>>>,
poll_calls: RefCell<u32>,
status_sequence: RefCell<VecDeque<Result<DumpTask, Diagnostic>>>,
download_bytes: Option<Vec<u8>>,
download_error: Option<Diagnostic>,
download_calls: RefCell<u32>,
delete_result: Option<Result<(), Diagnostic>>,
delete_calls: RefCell<u32>,
list_projects_result: Option<Result<Vec<Project>, Diagnostic>>,
list_projects_calls: RefCell<u32>,
list_projects_token: RefCell<Option<Option<String>>>,
describe_project_result: Option<Result<ProjectDetail, Diagnostic>>,
describe_project_call: RefCell<Option<(String, Option<String>)>>,
call_log: RefCell<Vec<CallRecord>>,
}
impl MockDspClient {
fn new() -> Self {
Self {
resolve_result: None,
resolve_calls: RefCell::new(0),
create_result: None,
create_sequence: RefCell::new(VecDeque::new()),
create_calls: RefCell::new(0),
create_skip_assets: Cell::new(None),
poll_sequence: RefCell::new(VecDeque::new()),
poll_calls: RefCell::new(0),
status_sequence: RefCell::new(VecDeque::new()),
download_bytes: None,
download_error: None,
download_calls: RefCell::new(0),
delete_result: None,
delete_calls: RefCell::new(0),
list_projects_result: None,
list_projects_calls: RefCell::new(0),
list_projects_token: RefCell::new(None),
describe_project_result: None,
describe_project_call: RefCell::new(None),
call_log: RefCell::new(Vec::new()),
}
}
fn with_resolve_project(mut self, result: Result<ProjectRef, Diagnostic>) -> Self {
self.resolve_result = Some(result);
self
}
fn with_create_dump(mut self, result: Result<CreateDumpOutcome, Diagnostic>) -> Self {
self.create_result = Some(result);
self
}
fn with_create_exists(mut self, id: impl Into<String>) -> Self {
self.create_result = Some(Ok(CreateDumpOutcome::Exists { id: id.into() }));
self
}
fn with_create_exists_other_project(
mut self,
id: impl Into<String>,
project_iri: impl Into<String>,
) -> Self {
self.create_result = Some(Ok(CreateDumpOutcome::ExistsForOtherProject {
id: id.into(),
project_iri: project_iri.into(),
}));
self
}
fn with_create_sequence(
mut self,
seq: impl IntoIterator<Item = Result<CreateDumpOutcome, Diagnostic>>,
) -> Self {
self.create_sequence = RefCell::new(seq.into_iter().collect());
self
}
fn with_poll_sequence(
mut self,
seq: impl IntoIterator<Item = Result<DumpTask, Diagnostic>>,
) -> Self {
self.poll_sequence = RefCell::new(seq.into_iter().collect());
self
}
fn with_status_sequence(
mut self,
seq: impl IntoIterator<Item = Result<DumpTask, Diagnostic>>,
) -> Self {
self.status_sequence = RefCell::new(seq.into_iter().collect());
self
}
fn with_download_bytes(mut self, bytes: Vec<u8>) -> Self {
self.download_bytes = Some(bytes);
self
}
fn with_download_error(mut self, err: Diagnostic) -> Self {
self.download_error = Some(err);
self
}
fn with_delete_result(mut self, result: Result<(), Diagnostic>) -> Self {
self.delete_result = Some(result);
self
}
fn with_list_projects_result(mut self, result: Result<Vec<Project>, Diagnostic>) -> Self {
self.list_projects_result = Some(result);
self
}
fn with_describe_project_result(
mut self,
result: Result<ProjectDetail, Diagnostic>,
) -> Self {
self.describe_project_result = Some(result);
self
}
fn call_log(&self) -> Vec<CallRecord> {
self.call_log.borrow().clone()
}
fn list_projects_token(&self) -> Option<String> {
self.list_projects_token
.borrow()
.as_ref()
.expect("list_projects was not called")
.clone()
}
fn describe_project_call(&self) -> (String, Option<String>) {
self.describe_project_call
.borrow()
.clone()
.expect("describe_project was not called")
}
fn describe_project_was_called(&self) -> bool {
self.describe_project_call.borrow().is_some()
}
}
impl DspClient for MockDspClient {
fn login(
&self,
_server: &str,
_user: &str,
_password: &str,
) -> Result<crate::model::LoginResponse, Diagnostic> {
unimplemented!("login not used in dump tests")
}
fn resolve_project(&self, _server: &str, _project: &str) -> Result<ProjectRef, Diagnostic> {
*self.resolve_calls.borrow_mut() += 1;
self.call_log.borrow_mut().push(CallRecord::Resolve);
self.resolve_result
.clone()
.expect("resolve_result must be set when resolve_project is called")
}
fn create_project_dump(
&self,
_server: &str,
project_iri: &str,
skip_assets: bool,
_token: &str,
) -> Result<CreateDumpOutcome, Diagnostic> {
*self.create_calls.borrow_mut() += 1;
self.call_log.borrow_mut().push(CallRecord::Create {
project_iri: project_iri.to_string(),
});
self.create_skip_assets.set(Some(skip_assets));
if *self.create_calls.borrow() > 1
&& let Some(result) = self.create_sequence.borrow_mut().pop_front()
{
return result;
}
self.create_result
.clone()
.expect("create_result must be set when create_project_dump is called")
}
fn get_project_dump_status(
&self,
_server: &str,
project_iri: &str,
_dump_id: &str,
_token: &str,
) -> Result<DumpTask, Diagnostic> {
let from_status = self.status_sequence.borrow_mut().pop_front();
if let Some(result) = from_status {
self.call_log
.borrow_mut()
.push(CallRecord::Status(project_iri.to_string()));
return result;
}
*self.poll_calls.borrow_mut() += 1;
self.call_log.borrow_mut().push(CallRecord::Poll);
self.poll_sequence
.borrow_mut()
.pop_front()
.expect("poll_sequence exhausted — test bug: provide enough entries or let the logical clock fire first")
}
fn download_project_dump(
&self,
_server: &str,
_project_iri: &str,
_dump_id: &str,
_token: &str,
dest: &mut dyn std::io::Write,
) -> Result<u64, Diagnostic> {
*self.download_calls.borrow_mut() += 1;
self.call_log.borrow_mut().push(CallRecord::Download);
if let Some(ref e) = self.download_error {
return Err(e.clone());
}
let bytes = self.download_bytes.as_deref().unwrap_or(&[]);
dest.write_all(bytes)
.map_err(|e| Diagnostic::Internal(format!("mock write error: {e}")))?;
Ok(bytes.len() as u64)
}
fn delete_project_dump(
&self,
_server: &str,
project_iri: &str,
_dump_id: &str,
_token: &str,
) -> Result<(), Diagnostic> {
*self.delete_calls.borrow_mut() += 1;
self.call_log
.borrow_mut()
.push(CallRecord::Delete(project_iri.to_string()));
self.delete_result
.clone()
.expect("delete_result must be set when delete_project_dump is called")
}
fn list_projects(
&self,
_server: &str,
token: Option<&str>,
) -> Result<Vec<crate::model::Project>, Diagnostic> {
*self.list_projects_calls.borrow_mut() += 1;
*self.list_projects_token.borrow_mut() = Some(token.map(str::to_owned));
self.call_log.borrow_mut().push(CallRecord::ListProjects {
token: token.map(str::to_owned),
});
match &self.list_projects_result {
Some(r) => r.clone(),
None => Err(Diagnostic::NotImplemented(
"list_projects not configured in MockDspClient".into(),
)),
}
}
fn describe_project(
&self,
_server: &str,
project: &str,
token: Option<&str>,
) -> Result<crate::model::ProjectDetail, Diagnostic> {
*self.describe_project_call.borrow_mut() =
Some((project.to_owned(), token.map(str::to_owned)));
match &self.describe_project_result {
Some(r) => r.clone(),
None => Err(Diagnostic::NotImplemented(
"describe_project not configured in MockDspClient".into(),
)),
}
}
fn list_data_models(
&self,
_server: &str,
_project_iri: &str,
_token: Option<&str>,
) -> Result<Vec<crate::model::DataModel>, Diagnostic> {
unimplemented!("list_data_models not used by project tests")
}
fn describe_data_model(
&self,
_server: &str,
_data_model_iri: &str,
_token: Option<&str>,
) -> Result<crate::model::DataModelDetail, Diagnostic> {
unimplemented!("describe_data_model not used in project tests")
}
fn describe_resource_type(
&self,
_server: &str,
_data_model_iri: &str,
_resource_type: &str,
_token: Option<&str>,
) -> Result<crate::model::ResourceTypeDetail, Diagnostic> {
unimplemented!("describe_resource_type not used in project tests")
}
fn data_model_structure(
&self,
_server: &str,
_data_model_iri: &str,
_token: Option<&str>,
) -> Result<crate::model::DataModelStructure, Diagnostic> {
unimplemented!("data_model_structure not used in project tests")
}
fn list_resources(
&self,
_server: &str,
_project_iri: &str,
_resource_type_iri: &str,
_order_by: Option<&str>,
_page: u32,
_token: Option<&str>,
) -> Result<crate::model::ResourcePage, Diagnostic> {
unimplemented!("list_resources not used in project tests")
}
fn describe_resource(
&self,
_server: &str,
_resource_iri: &str,
_token: Option<&str>,
_with_values: bool,
) -> Result<crate::model::ResourceDetail, Diagnostic> {
unimplemented!("describe_resource not used in project tests")
}
fn verify_token(&self, _server: &str, _token: &str) -> Result<(), Diagnostic> {
unimplemented!("verify_token not used by dump tests")
}
}
struct RecordingRenderer {
dump_outcome: Option<DumpOutcome>,
dump_meta: Option<MetaContext>,
dump_deleted_outcome: Option<DumpDeleteOutcome>,
dump_deleted_meta: Option<MetaContext>,
projects_view: Option<(Vec<Project>, usize, Option<String>)>,
projects_meta: Option<MetaContext>,
describe_detail: Option<ProjectDetail>,
describe_meta: Option<MetaContext>,
}
impl RecordingRenderer {
fn new() -> Self {
Self {
dump_outcome: None,
dump_meta: None,
dump_deleted_outcome: None,
dump_deleted_meta: None,
projects_view: None,
projects_meta: None,
describe_detail: None,
describe_meta: None,
}
}
}
impl Renderer for RecordingRenderer {
fn diagnostic(
&mut self,
_diag: &Diagnostic,
_meta: &MetaContext,
) -> Result<(), Diagnostic> {
Ok(())
}
fn auth_login(
&mut self,
_outcome: &AuthLoginOutcome,
_meta: &MetaContext,
) -> Result<(), Diagnostic> {
Ok(())
}
fn auth_status(
&mut self,
_outcome: &AuthStatusOutcome,
_meta: &MetaContext,
) -> Result<(), Diagnostic> {
Ok(())
}
fn auth_logout(
&mut self,
_outcome: &AuthLogoutOutcome,
_meta: &MetaContext,
) -> Result<(), Diagnostic> {
Ok(())
}
fn auth_set_token(
&mut self,
_outcome: &AuthSetTokenOutcome,
_meta: &MetaContext,
) -> Result<(), Diagnostic> {
Ok(())
}
fn project_dump(
&mut self,
outcome: &DumpOutcome,
meta: &MetaContext,
) -> Result<(), Diagnostic> {
self.dump_outcome = Some(DumpOutcome {
path: outcome.path.clone(),
bytes: outcome.bytes,
cleaned_up: outcome.cleaned_up,
reused: outcome.reused,
created_at: outcome.created_at,
});
self.dump_meta = Some(meta.clone());
Ok(())
}
fn project_dump_deleted(
&mut self,
outcome: &DumpDeleteOutcome,
meta: &MetaContext,
) -> Result<(), Diagnostic> {
self.dump_deleted_outcome = Some(DumpDeleteOutcome {
deleted: outcome.deleted,
note: outcome.note.clone(),
});
self.dump_deleted_meta = Some(meta.clone());
Ok(())
}
fn projects(
&mut self,
view: &ProjectListView,
meta: &MetaContext,
) -> Result<(), Diagnostic> {
self.projects_view = Some((view.items.clone(), view.total, view.filter.clone()));
self.projects_meta = Some(meta.clone());
Ok(())
}
fn project_describe(
&mut self,
project: &ProjectDetail,
meta: &MetaContext,
) -> Result<(), Diagnostic> {
self.describe_detail = Some(project.clone());
self.describe_meta = Some(meta.clone());
Ok(())
}
fn data_models(
&mut self,
_view: &crate::render::DataModelListView,
_meta: &MetaContext,
) -> Result<(), Diagnostic> {
Ok(())
}
fn data_model_describe(
&mut self,
_detail: &crate::model::DataModelDetail,
_meta: &MetaContext,
) -> Result<(), Diagnostic> {
Ok(())
}
fn resource_types(
&mut self,
_view: &crate::render::ResourceTypeListView,
_meta: &MetaContext,
) -> Result<(), Diagnostic> {
Ok(())
}
fn resource_type_describe(
&mut self,
_detail: &crate::model::ResourceTypeDetail,
_meta: &MetaContext,
) -> Result<(), Diagnostic> {
unimplemented!("resource_type_describe not used in project action tests")
}
fn data_model_structure(
&mut self,
_structure: &crate::model::DataModelStructure,
_meta: &MetaContext,
) -> Result<(), Diagnostic> {
unimplemented!("data_model_structure not used in project action tests")
}
fn resources(
&mut self,
_view: &crate::render::ResourceListView,
_meta: &MetaContext,
) -> Result<(), Diagnostic> {
Ok(())
}
fn resource_describe(
&mut self,
_detail: &crate::model::ResourceDetail,
_meta: &MetaContext,
) -> Result<(), Diagnostic> {
Ok(())
}
}
struct RecordingProgressReporter {
events: Vec<EventRecord>,
}
#[derive(Debug, PartialEq)]
enum EventRecord {
Triggered(String),
Polling(u64),
Downloading,
Done(u64),
Adopting(String),
Deleting(String),
ProbeCreated(String),
DiscardingOtherProjectDump { id: String, project_iri: String },
}
impl RecordingProgressReporter {
fn new() -> Self {
Self { events: Vec::new() }
}
}
impl ProgressReporter for RecordingProgressReporter {
fn report(&mut self, event: &DumpEvent) -> Result<(), Diagnostic> {
match event {
DumpEvent::Triggered { id } => self.events.push(EventRecord::Triggered(id.clone())),
DumpEvent::Polling { elapsed_secs, .. } => {
self.events.push(EventRecord::Polling(*elapsed_secs))
}
DumpEvent::Downloading => self.events.push(EventRecord::Downloading),
DumpEvent::Done { bytes } => self.events.push(EventRecord::Done(*bytes)),
DumpEvent::Adopting { id } => self.events.push(EventRecord::Adopting(id.clone())),
DumpEvent::Deleting { id } => self.events.push(EventRecord::Deleting(id.clone())),
DumpEvent::ProbeCreated { id } => {
self.events.push(EventRecord::ProbeCreated(id.clone()))
}
DumpEvent::DiscardingOtherProjectDump { id, project_iri } => {
self.events.push(EventRecord::DiscardingOtherProjectDump {
id: id.clone(),
project_iri: project_iri.clone(),
})
}
}
Ok(())
}
}
fn fixed_now() -> chrono::DateTime<Utc> {
Utc.with_ymd_and_hms(2026, 5, 29, 12, 0, 0).unwrap()
}
fn make_project_ref() -> ProjectRef {
ProjectRef {
iri: "http://rdfh.ch/projects/0001".to_string(),
shortcode: "0001".to_string(),
shortname: "anything".to_string(),
}
}
fn make_dump_task(status: DumpStatus) -> DumpTask {
DumpTask {
id: "dump-id-42".to_string(),
status,
error_message: None,
created_at: None,
}
}
fn created_task(status: DumpStatus) -> CreateDumpOutcome {
CreateDumpOutcome::Created(make_dump_task(status))
}
fn make_args(dir: &TempDir) -> (ProjectDumpArgs, Config) {
let args = ProjectDumpArgs {
server: Some("https://api.test.dasch.swiss".to_string()),
project: Some("0001".to_string()),
skip_assets: false,
output: Some(dir.path().join("out.zip")),
force: false,
cleanup: false,
timeout: 3600,
replace: false,
delete: false,
discard_other_project: false,
format: FormatArgs {
format: Format::Prose,
json: false,
lines: false,
columns: None,
no_header: false,
header_only: false,
},
};
let cfg = Config {
server: "https://api.test.dasch.swiss".to_string(),
};
(args, cfg)
}
fn no_op_sleeper() -> impl Fn(Duration) {
|_| {}
}
#[test]
fn happy_path_resolve_trigger_poll_download() {
let dir = TempDir::new().unwrap();
let (args, cfg) = make_args(&dir);
let client = MockDspClient::new()
.with_resolve_project(Ok(make_project_ref()))
.with_create_dump(Ok(created_task(DumpStatus::InProgress)))
.with_poll_sequence([
Ok(make_dump_task(DumpStatus::InProgress)),
Ok(make_dump_task(DumpStatus::Completed)),
])
.with_download_bytes(b"PK fake zip content".to_vec());
let mut renderer = RecordingRenderer::new();
let mut reporter = RecordingProgressReporter::new();
run_impl(
&args,
&cfg,
&client,
&mut renderer,
&mut reporter,
Some("env-token-abc".to_string()),
&no_op_sleeper(),
fixed_now(),
None,
dir.path(),
)
.unwrap();
let outcome = renderer.dump_outcome.unwrap();
assert_eq!(outcome.bytes, 19); assert!(!outcome.cleaned_up);
assert!(
outcome.path.exists(),
"output file must exist after happy-path download"
);
assert!(
outcome.path.metadata().unwrap().len() > 0,
"output file must be non-empty after happy-path download"
);
assert_eq!(
reporter.events[0],
EventRecord::Triggered("dump-id-42".to_string())
);
assert_eq!(reporter.events[1], EventRecord::Polling(0));
assert_eq!(reporter.events[2], EventRecord::Downloading);
assert_eq!(reporter.events[3], EventRecord::Done(19));
assert_eq!(reporter.events.len(), 4);
}
#[test]
fn default_filename_uses_shortcode_and_fixed_timestamp() {
let dir = TempDir::new().unwrap();
let cache_path = dir.path().join("auth.toml");
let args = ProjectDumpArgs {
server: Some("https://api.test.dasch.swiss".to_string()),
project: Some("0001".to_string()),
skip_assets: false,
output: None,
force: true, cleanup: false,
timeout: 3600,
replace: false,
delete: false,
discard_other_project: false,
format: FormatArgs {
format: Format::Prose,
json: false,
lines: false,
columns: None,
no_header: false,
header_only: false,
},
};
let cfg = Config {
server: "https://api.test.dasch.swiss".to_string(),
};
let client = MockDspClient::new()
.with_resolve_project(Ok(make_project_ref()))
.with_create_dump(Ok(created_task(DumpStatus::InProgress)))
.with_poll_sequence([Ok(make_dump_task(DumpStatus::Completed))])
.with_download_bytes(b"zip".to_vec());
let mut renderer = RecordingRenderer::new();
let mut reporter = RecordingProgressReporter::new();
run_impl(
&args,
&cfg,
&client,
&mut renderer,
&mut reporter,
Some("tok".to_string()),
&no_op_sleeper(),
fixed_now(),
Some(&cache_path),
dir.path(),
)
.unwrap();
let outcome = renderer.dump_outcome.unwrap();
let expected_path = dir.path().join("0001-20260529T120000Z.zip");
assert_eq!(outcome.path, expected_path);
}
#[test]
fn explicit_output_override_respected() {
let dir = TempDir::new().unwrap();
let out_path = dir.path().join("custom.zip");
let (mut args, cfg) = make_args(&dir);
args.output = Some(out_path.clone());
let client = MockDspClient::new()
.with_resolve_project(Ok(make_project_ref()))
.with_create_dump(Ok(created_task(DumpStatus::InProgress)))
.with_poll_sequence([Ok(make_dump_task(DumpStatus::Completed))])
.with_download_bytes(b"data".to_vec());
let mut renderer = RecordingRenderer::new();
let mut reporter = RecordingProgressReporter::new();
run_impl(
&args,
&cfg,
&client,
&mut renderer,
&mut reporter,
Some("tok".to_string()),
&no_op_sleeper(),
fixed_now(),
None,
dir.path(),
)
.unwrap();
let outcome = renderer.dump_outcome.unwrap();
assert_eq!(outcome.path, out_path);
assert!(out_path.exists());
}
#[test]
fn explicit_output_exists_no_force_returns_usage_before_any_client_call() {
let dir = TempDir::new().unwrap();
let out_path = dir.path().join("existing.zip");
std::fs::write(&out_path, b"existing").unwrap();
let (mut args, cfg) = make_args(&dir);
args.output = Some(out_path.clone());
args.force = false;
let client = MockDspClient::new();
let mut renderer = RecordingRenderer::new();
let mut reporter = RecordingProgressReporter::new();
let err = run_impl(
&args,
&cfg,
&client,
&mut renderer,
&mut reporter,
Some("tok".to_string()),
&no_op_sleeper(),
fixed_now(),
None,
dir.path(),
)
.unwrap_err();
assert!(
matches!(err, Diagnostic::Usage(_)),
"expected Usage, got {err:?}"
);
assert!(
err.to_string().contains("refusing to overwrite"),
"message should mention overwrite refusal: {err}"
);
assert_eq!(
*client.resolve_calls.borrow(),
0,
"resolve_project must not be called"
);
assert_eq!(
*client.create_calls.borrow(),
0,
"create must not be called"
);
}
#[test]
fn default_output_exists_no_force_returns_usage_after_resolve_before_trigger() {
let dir = TempDir::new().unwrap();
let cache_path = dir.path().join("auth.toml");
let args = ProjectDumpArgs {
server: Some("https://api.test.dasch.swiss".to_string()),
project: Some("0001".to_string()),
skip_assets: false,
output: None,
force: false,
cleanup: false,
timeout: 3600,
replace: false,
delete: false,
discard_other_project: false,
format: FormatArgs {
format: Format::Prose,
json: false,
lines: false,
columns: None,
no_header: false,
header_only: false,
},
};
let cfg = Config {
server: "https://api.test.dasch.swiss".to_string(),
};
let client = MockDspClient::new().with_resolve_project(Ok(make_project_ref()));
let mut renderer = RecordingRenderer::new();
let mut reporter = RecordingProgressReporter::new();
let default_path_in_tempdir = dir.path().join("0001-20260529T120000Z.zip");
std::fs::write(&default_path_in_tempdir, b"existing")
.expect("must be able to write conflict file into tempdir");
let err = run_impl(
&args,
&cfg,
&client,
&mut renderer,
&mut reporter,
Some("tok".to_string()),
&no_op_sleeper(),
fixed_now(),
Some(&cache_path),
dir.path(),
)
.unwrap_err();
assert!(
matches!(err, Diagnostic::Usage(_)),
"expected Usage, got {err:?}"
);
assert!(
err.to_string().contains("refusing to overwrite"),
"message should mention overwrite refusal: {err}"
);
assert_eq!(
*client.resolve_calls.borrow(),
1,
"resolve must have been called (default-path guard runs after resolve)"
);
assert_eq!(
*client.create_calls.borrow(),
0,
"trigger must NOT have been called (guard fires before trigger)"
);
}
#[test]
fn missing_token_returns_auth_required_with_no_client_calls() {
let dir = TempDir::new().unwrap();
let cache_path = dir.path().join("auth.toml");
let (args, cfg) = make_args(&dir);
let client = MockDspClient::new();
let mut renderer = RecordingRenderer::new();
let mut reporter = RecordingProgressReporter::new();
let err = run_impl(
&args,
&cfg,
&client,
&mut renderer,
&mut reporter,
None, &no_op_sleeper(),
fixed_now(),
Some(&cache_path), dir.path(),
)
.unwrap_err();
assert!(
matches!(err, Diagnostic::AuthRequired(_)),
"expected AuthRequired, got {err:?}"
);
assert_eq!(
*client.resolve_calls.borrow(),
0,
"resolve must not be called"
);
assert_eq!(
*client.create_calls.borrow(),
0,
"trigger must not be called"
);
}
#[test]
fn trigger_conflict_propagates() {
let dir = TempDir::new().unwrap();
let (args, cfg) = make_args(&dir);
let client = MockDspClient::new()
.with_resolve_project(Ok(make_project_ref()))
.with_create_dump(Err(Diagnostic::Conflict(
"a dump for this project is already in progress or present".to_string(),
)));
let mut renderer = RecordingRenderer::new();
let mut reporter = RecordingProgressReporter::new();
let err = run_impl(
&args,
&cfg,
&client,
&mut renderer,
&mut reporter,
Some("tok".to_string()),
&no_op_sleeper(),
fixed_now(),
None,
dir.path(),
)
.unwrap_err();
assert!(
matches!(err, Diagnostic::Conflict(_)),
"expected Conflict, got {err:?}"
);
}
#[test]
fn poll_failed_returns_server_error() {
let dir = TempDir::new().unwrap();
let (args, cfg) = make_args(&dir);
let failed_task = DumpTask {
id: "dump-id-42".to_string(),
status: DumpStatus::Failed,
error_message: Some("out of disk space".to_string()),
created_at: None,
};
let client = MockDspClient::new()
.with_resolve_project(Ok(make_project_ref()))
.with_create_dump(Ok(created_task(DumpStatus::InProgress)))
.with_poll_sequence([Ok(failed_task)]);
let mut renderer = RecordingRenderer::new();
let mut reporter = RecordingProgressReporter::new();
let err = run_impl(
&args,
&cfg,
&client,
&mut renderer,
&mut reporter,
Some("tok".to_string()),
&no_op_sleeper(),
fixed_now(),
None,
dir.path(),
)
.unwrap_err();
assert!(
matches!(err, Diagnostic::ServerError(_)),
"expected ServerError, got {err:?}"
);
let msg = err.to_string();
assert!(
msg.contains("server-side dump failed"),
"message should mention dump failure: {msg}"
);
assert!(
msg.contains("out of disk space"),
"message should include error_message: {msg}"
);
}
#[test]
fn timeout_via_logical_clock_returns_server_error() {
let dir = TempDir::new().unwrap();
let (mut args, cfg) = make_args(&dir);
args.timeout = 1;
let in_progress: Vec<Result<DumpTask, Diagnostic>> = (0..100)
.map(|_| Ok(make_dump_task(DumpStatus::InProgress)))
.collect();
let client = MockDspClient::new()
.with_resolve_project(Ok(make_project_ref()))
.with_create_dump(Ok(created_task(DumpStatus::InProgress)))
.with_poll_sequence(in_progress);
let mut renderer = RecordingRenderer::new();
let mut reporter = RecordingProgressReporter::new();
let err = run_impl(
&args,
&cfg,
&client,
&mut renderer,
&mut reporter,
Some("tok".to_string()),
&no_op_sleeper(),
fixed_now(),
None,
dir.path(),
)
.unwrap_err();
assert!(
matches!(err, Diagnostic::ServerError(_)),
"expected ServerError timeout, got {err:?}"
);
let msg = err.to_string();
assert!(
msg.contains("did not complete"),
"message should mention timeout: {msg}"
);
assert!(
msg.contains("may still be running"),
"message should contain user-visible hint 'may still be running': {msg}"
);
}
#[test]
fn cleanup_success_sets_cleaned_up_true() {
let dir = TempDir::new().unwrap();
let (mut args, cfg) = make_args(&dir);
args.cleanup = true;
let client = MockDspClient::new()
.with_resolve_project(Ok(make_project_ref()))
.with_create_dump(Ok(created_task(DumpStatus::InProgress)))
.with_poll_sequence([Ok(make_dump_task(DumpStatus::Completed))])
.with_download_bytes(b"zip".to_vec())
.with_delete_result(Ok(()));
let mut renderer = RecordingRenderer::new();
let mut reporter = RecordingProgressReporter::new();
run_impl(
&args,
&cfg,
&client,
&mut renderer,
&mut reporter,
Some("tok".to_string()),
&no_op_sleeper(),
fixed_now(),
None,
dir.path(),
)
.unwrap();
let outcome = renderer.dump_outcome.unwrap();
assert!(
outcome.cleaned_up,
"cleanup success should set cleaned_up=true"
);
}
#[test]
fn cleanup_error_keeps_exit_ok_and_cleaned_up_false() {
let dir = TempDir::new().unwrap();
let (mut args, cfg) = make_args(&dir);
args.cleanup = true;
let client = MockDspClient::new()
.with_resolve_project(Ok(make_project_ref()))
.with_create_dump(Ok(created_task(DumpStatus::InProgress)))
.with_poll_sequence([Ok(make_dump_task(DumpStatus::Completed))])
.with_download_bytes(b"zip".to_vec())
.with_delete_result(Err(Diagnostic::ServerError("delete failed".to_string())));
let mut renderer = RecordingRenderer::new();
let mut reporter = RecordingProgressReporter::new();
run_impl(
&args,
&cfg,
&client,
&mut renderer,
&mut reporter,
Some("tok".to_string()),
&no_op_sleeper(),
fixed_now(),
None,
dir.path(),
)
.unwrap();
let outcome = renderer.dump_outcome.unwrap();
assert!(
!outcome.cleaned_up,
"cleanup error should set cleaned_up=false"
);
}
#[test]
fn download_error_leaves_no_file_at_target_path() {
let dir = TempDir::new().unwrap();
let out_path = dir.path().join("should-not-exist.zip");
let (mut args, cfg) = make_args(&dir);
args.output = Some(out_path.clone());
let client = MockDspClient::new()
.with_resolve_project(Ok(make_project_ref()))
.with_create_dump(Ok(created_task(DumpStatus::InProgress)))
.with_poll_sequence([Ok(make_dump_task(DumpStatus::Completed))])
.with_download_error(Diagnostic::Network("connection reset".to_string()));
let mut renderer = RecordingRenderer::new();
let mut reporter = RecordingProgressReporter::new();
let err = run_impl(
&args,
&cfg,
&client,
&mut renderer,
&mut reporter,
Some("tok".to_string()),
&no_op_sleeper(),
fixed_now(),
None,
dir.path(),
)
.unwrap_err();
assert!(
matches!(err, Diagnostic::Network(_)),
"expected Network error, got {err:?}"
);
assert!(
!out_path.exists(),
"target file must not exist after download error"
);
}
#[test]
fn default_output_path_pure_fn() {
let now = Utc.with_ymd_and_hms(2026, 5, 29, 12, 0, 0).unwrap();
let base = std::path::Path::new("/tmp/test-base");
let path = default_output_path(base, "0001", now);
assert_eq!(
path,
PathBuf::from("/tmp/test-base/0001-20260529T120000Z.zip")
);
}
#[test]
fn auth_state_env_token_reports_authenticated_via_dsp_token() {
let dir = TempDir::new().unwrap();
let (args, cfg) = make_args(&dir);
let client = MockDspClient::new()
.with_resolve_project(Ok(make_project_ref()))
.with_create_dump(Ok(created_task(DumpStatus::InProgress)))
.with_poll_sequence([Ok(make_dump_task(DumpStatus::Completed))])
.with_download_bytes(b"zip".to_vec());
let mut renderer = RecordingRenderer::new();
let mut reporter = RecordingProgressReporter::new();
run_impl(
&args,
&cfg,
&client,
&mut renderer,
&mut reporter,
Some("env-token".to_string()),
&no_op_sleeper(),
fixed_now(),
None,
dir.path(),
)
.unwrap();
let meta = renderer.dump_meta.unwrap();
assert_eq!(meta.auth_state, "authenticated via DSP_TOKEN");
}
#[test]
fn auth_state_cache_token_reports_authenticated() {
use crate::config::AuthCache;
use crate::config::auth_cache::ServerEntry;
let dir = TempDir::new().unwrap();
let cache_path = dir.path().join("auth.toml");
let out_path = dir.path().join("out.zip");
let mut cache = AuthCache::default();
cache.set_entry(
"https://api.test.dasch.swiss",
ServerEntry {
token: "cache-tok".to_string(),
user: None,
acquired_at: None,
expires_at: None,
},
);
cache.save_to(&cache_path).unwrap();
let args = ProjectDumpArgs {
server: Some("https://api.test.dasch.swiss".to_string()),
project: Some("0001".to_string()),
skip_assets: false,
output: Some(out_path),
force: false,
cleanup: false,
timeout: 3600,
replace: false,
delete: false,
discard_other_project: false,
format: FormatArgs {
format: Format::Prose,
json: false,
lines: false,
columns: None,
no_header: false,
header_only: false,
},
};
let cfg = Config {
server: "https://api.test.dasch.swiss".to_string(),
};
let client = MockDspClient::new()
.with_resolve_project(Ok(make_project_ref()))
.with_create_dump(Ok(created_task(DumpStatus::InProgress)))
.with_poll_sequence([Ok(make_dump_task(DumpStatus::Completed))])
.with_download_bytes(b"zip".to_vec());
let mut renderer = RecordingRenderer::new();
let mut reporter = RecordingProgressReporter::new();
run_impl(
&args,
&cfg,
&client,
&mut renderer,
&mut reporter,
None, &no_op_sleeper(),
fixed_now(),
Some(&cache_path),
dir.path(),
)
.unwrap();
let meta = renderer.dump_meta.unwrap();
assert_eq!(meta.auth_state, "authenticated");
}
#[test]
fn rename_failure_returns_io_and_temp_cleaned_up() {
let dir = TempDir::new().unwrap();
let final_path = dir.path().join("dump_dir");
std::fs::create_dir(&final_path).unwrap();
let (mut args, cfg) = make_args(&dir);
args.output = Some(final_path.clone());
args.force = true;
let client = MockDspClient::new()
.with_resolve_project(Ok(make_project_ref()))
.with_create_dump(Ok(created_task(DumpStatus::InProgress)))
.with_poll_sequence([Ok(make_dump_task(DumpStatus::Completed))])
.with_download_bytes(b"zip".to_vec());
let mut renderer = RecordingRenderer::new();
let mut reporter = RecordingProgressReporter::new();
let err = run_impl(
&args,
&cfg,
&client,
&mut renderer,
&mut reporter,
Some("tok".to_string()),
&no_op_sleeper(),
fixed_now(),
None,
dir.path(),
)
.unwrap_err();
assert!(
matches!(err, Diagnostic::Io(_)),
"expected Io error for rename failure, got {err:?}"
);
let pid = std::process::id();
let temp = dir.path().join(format!("dump_dir.{pid}.partial"));
assert!(
!temp.exists(),
"temp file should be cleaned up after rename failure"
);
}
fn assert_download_error_leaves_no_file(download_error: Diagnostic) {
let dir = TempDir::new().unwrap();
let out_path = dir.path().join("should-not-exist.zip");
let (mut args, cfg) = make_args(&dir);
args.output = Some(out_path.clone());
let client = MockDspClient::new()
.with_resolve_project(Ok(make_project_ref()))
.with_create_dump(Ok(created_task(DumpStatus::InProgress)))
.with_poll_sequence([Ok(make_dump_task(DumpStatus::Completed))])
.with_download_error(download_error);
let mut renderer = RecordingRenderer::new();
let mut reporter = RecordingProgressReporter::new();
let err = run_impl(
&args,
&cfg,
&client,
&mut renderer,
&mut reporter,
Some("tok".to_string()),
&no_op_sleeper(),
fixed_now(),
None,
dir.path(),
)
.unwrap_err();
assert!(
!err.to_string().is_empty(),
"error message must be non-empty"
);
assert!(
!out_path.exists(),
"target file must not exist after download error ({err:?})"
);
}
#[test]
fn download_io_error_leaves_no_file_at_target_path() {
assert_download_error_leaves_no_file(Diagnostic::Io(
"failed to write /tmp/test.zip: no space left on device".to_string(),
));
}
#[test]
fn skip_assets_true_is_passed_through_to_create_project_dump() {
let dir = TempDir::new().unwrap();
let (mut args, cfg) = make_args(&dir);
args.skip_assets = true;
let client = MockDspClient::new()
.with_resolve_project(Ok(make_project_ref()))
.with_create_dump(Ok(created_task(DumpStatus::InProgress)))
.with_poll_sequence([Ok(make_dump_task(DumpStatus::Completed))])
.with_download_bytes(b"zip".to_vec());
let mut renderer = RecordingRenderer::new();
let mut reporter = RecordingProgressReporter::new();
run_impl(
&args,
&cfg,
&client,
&mut renderer,
&mut reporter,
Some("tok".to_string()),
&no_op_sleeper(),
fixed_now(),
None,
dir.path(),
)
.unwrap();
assert_eq!(
client.create_skip_assets.get(),
Some(true),
"skip_assets=true must be forwarded to create_project_dump"
);
}
#[test]
fn skip_assets_false_is_passed_through_to_create_project_dump() {
let dir = TempDir::new().unwrap();
let (mut args, cfg) = make_args(&dir);
args.skip_assets = false;
let client = MockDspClient::new()
.with_resolve_project(Ok(make_project_ref()))
.with_create_dump(Ok(created_task(DumpStatus::InProgress)))
.with_poll_sequence([Ok(make_dump_task(DumpStatus::Completed))])
.with_download_bytes(b"zip".to_vec());
let mut renderer = RecordingRenderer::new();
let mut reporter = RecordingProgressReporter::new();
run_impl(
&args,
&cfg,
&client,
&mut renderer,
&mut reporter,
Some("tok".to_string()),
&no_op_sleeper(),
fixed_now(),
None,
dir.path(),
)
.unwrap();
assert_eq!(
client.create_skip_assets.get(),
Some(false),
"skip_assets=false must be forwarded to create_project_dump"
);
}
#[test]
fn default_fresh_created_no_existing_dump() {
use chrono::TimeZone;
let dir = TempDir::new().unwrap();
let (args, cfg) = make_args(&dir);
let ts = Utc.with_ymd_and_hms(2026, 5, 20, 14, 3, 0).unwrap();
let client = MockDspClient::new()
.with_resolve_project(Ok(make_project_ref()))
.with_create_dump(Ok(CreateDumpOutcome::Created(DumpTask {
id: "dump-id-42".into(),
status: DumpStatus::InProgress,
error_message: None,
created_at: Some(ts),
})))
.with_poll_sequence([Ok(make_dump_task(DumpStatus::Completed))])
.with_download_bytes(b"zipdata".to_vec());
let mut renderer = RecordingRenderer::new();
let mut reporter = RecordingProgressReporter::new();
run_impl(
&args,
&cfg,
&client,
&mut renderer,
&mut reporter,
Some("tok".to_string()),
&no_op_sleeper(),
fixed_now(),
None,
dir.path(),
)
.unwrap();
let outcome = renderer.dump_outcome.unwrap();
assert!(!outcome.reused, "fresh dump must have reused:false");
assert_eq!(
outcome.created_at,
Some(ts),
"created_at must be populated from task"
);
assert_eq!(outcome.bytes, 7); let log = client.call_log();
assert!(log.contains(&CallRecord::Resolve));
assert!(log.contains(&CallRecord::Create {
project_iri: "http://rdfh.ch/projects/0001".to_string(),
}));
assert!(log.contains(&CallRecord::Poll));
assert!(log.contains(&CallRecord::Download));
}
#[test]
fn default_adopt_completed_existing_dump() {
use chrono::TimeZone;
let dir = TempDir::new().unwrap();
let (args, cfg) = make_args(&dir);
let ts = Utc.with_ymd_and_hms(2026, 5, 15, 10, 0, 0).unwrap();
let existing_task = DumpTask {
id: "existing-id".into(),
status: DumpStatus::Completed,
error_message: None,
created_at: Some(ts),
};
let client = MockDspClient::new()
.with_resolve_project(Ok(make_project_ref()))
.with_create_exists("existing-id")
.with_status_sequence([Ok(existing_task)])
.with_download_bytes(b"existing".to_vec());
let mut renderer = RecordingRenderer::new();
let mut reporter = RecordingProgressReporter::new();
run_impl(
&args,
&cfg,
&client,
&mut renderer,
&mut reporter,
Some("tok".to_string()),
&no_op_sleeper(),
fixed_now(),
None,
dir.path(),
)
.unwrap();
let outcome = renderer.dump_outcome.unwrap();
assert!(outcome.reused, "adopted dump must have reused:true");
assert_eq!(
outcome.created_at,
Some(ts),
"created_at must come from status"
);
assert!(
reporter
.events
.contains(&EventRecord::Adopting("existing-id".into())),
"must report Adopting"
);
assert!(
!reporter
.events
.iter()
.any(|e| matches!(e, EventRecord::Triggered(_))),
"must NOT report Triggered when adopting"
);
let log = client.call_log();
assert_eq!(log[0], CallRecord::Resolve);
assert_eq!(
log[1],
CallRecord::Create {
project_iri: "http://rdfh.ch/projects/0001".to_string(),
}
);
assert_eq!(
log[2],
CallRecord::Status("http://rdfh.ch/projects/0001".to_string())
);
assert_eq!(log[3], CallRecord::Download);
}
#[test]
fn default_adopt_in_progress_polls_then_downloads() {
use chrono::TimeZone;
let dir = TempDir::new().unwrap();
let (args, cfg) = make_args(&dir);
let ts = Utc.with_ymd_and_hms(2026, 5, 10, 8, 0, 0).unwrap();
let in_progress_task = DumpTask {
id: "adopt-ip-id".into(),
status: DumpStatus::InProgress,
error_message: None,
created_at: Some(ts),
};
let client = MockDspClient::new()
.with_resolve_project(Ok(make_project_ref()))
.with_create_exists("adopt-ip-id")
.with_status_sequence([Ok(in_progress_task)])
.with_poll_sequence([
Ok(make_dump_task(DumpStatus::InProgress)),
Ok(make_dump_task(DumpStatus::Completed)),
])
.with_download_bytes(b"data".to_vec());
let mut renderer = RecordingRenderer::new();
let mut reporter = RecordingProgressReporter::new();
run_impl(
&args,
&cfg,
&client,
&mut renderer,
&mut reporter,
Some("tok".to_string()),
&no_op_sleeper(),
fixed_now(),
None,
dir.path(),
)
.unwrap();
let outcome = renderer.dump_outcome.unwrap();
assert!(outcome.reused);
assert_eq!(outcome.created_at, Some(ts));
let log = client.call_log();
assert_eq!(log[0], CallRecord::Resolve, "first call must be Resolve");
assert_eq!(
log[1],
CallRecord::Create {
project_iri: "http://rdfh.ch/projects/0001".to_string(),
},
"second call must be Create"
);
assert_eq!(
log[2],
CallRecord::Status("http://rdfh.ch/projects/0001".to_string()),
"third call must be Status"
);
assert_eq!(
log[3],
CallRecord::Poll,
"fourth call must be Poll (in_progress)"
);
assert_eq!(
log[4],
CallRecord::Poll,
"fifth call must be Poll (completed)"
);
assert_eq!(log[5], CallRecord::Download, "sixth call must be Download");
assert_eq!(log.len(), 6, "must be exactly 6 calls");
let adopting_idx = reporter
.events
.iter()
.position(|e| matches!(e, EventRecord::Adopting(_)))
.expect("Adopting event must be present");
let first_polling_idx = reporter
.events
.iter()
.position(|e| matches!(e, EventRecord::Polling(_)))
.expect("Polling event must be present");
assert!(
adopting_idx < first_polling_idx,
"Adopting must be reported before the first Polling event"
);
}
#[test]
fn default_existing_failed_returns_conflict_with_hint() {
let dir = TempDir::new().unwrap();
let (args, cfg) = make_args(&dir);
let failed_task = DumpTask {
id: "fail-id".into(),
status: DumpStatus::Failed,
error_message: Some("disk full".into()),
created_at: None,
};
let client = MockDspClient::new()
.with_resolve_project(Ok(make_project_ref()))
.with_create_exists("fail-id")
.with_status_sequence([Ok(failed_task)]);
let mut renderer = RecordingRenderer::new();
let mut reporter = RecordingProgressReporter::new();
let err = run_impl(
&args,
&cfg,
&client,
&mut renderer,
&mut reporter,
Some("tok".to_string()),
&no_op_sleeper(),
fixed_now(),
None,
dir.path(),
)
.unwrap_err();
assert!(
matches!(err, Diagnostic::Conflict(_)),
"failed existing dump must yield Conflict"
);
let msg = err.to_string();
assert!(
msg.contains("existing dump failed"),
"message must mention failure: {msg}"
);
assert!(
msg.contains("disk full"),
"message must include server error: {msg}"
);
assert!(
msg.contains("--replace"),
"message must hint at --replace: {msg}"
);
assert!(
msg.contains("--delete"),
"message must hint at --delete: {msg}"
);
}
#[test]
fn replace_none_existing_creates_fresh() {
let dir = TempDir::new().unwrap();
let (mut args, cfg) = make_args(&dir);
args.replace = true;
let client = MockDspClient::new()
.with_resolve_project(Ok(make_project_ref()))
.with_create_dump(Ok(created_task(DumpStatus::InProgress)))
.with_poll_sequence([Ok(make_dump_task(DumpStatus::Completed))])
.with_download_bytes(b"fresh".to_vec());
let mut renderer = RecordingRenderer::new();
let mut reporter = RecordingProgressReporter::new();
run_impl(
&args,
&cfg,
&client,
&mut renderer,
&mut reporter,
Some("tok".to_string()),
&no_op_sleeper(),
fixed_now(),
None,
dir.path(),
)
.unwrap();
let outcome = renderer.dump_outcome.unwrap();
assert!(!outcome.reused, "replace with no existing → reused:false");
assert_eq!(*client.create_calls.borrow(), 1);
}
#[test]
fn replace_completed_deletes_then_recreates() {
let dir = TempDir::new().unwrap();
let (mut args, cfg) = make_args(&dir);
args.replace = true;
let existing_task = DumpTask {
id: "old-id".into(),
status: DumpStatus::Completed,
error_message: None,
created_at: None,
};
let task2 = DumpTask {
id: "new-id".into(),
status: DumpStatus::InProgress,
error_message: None,
created_at: None,
};
let client = MockDspClient::new()
.with_resolve_project(Ok(make_project_ref()))
.with_create_exists("old-id")
.with_create_sequence([Ok(CreateDumpOutcome::Created(task2))])
.with_status_sequence([Ok(existing_task)])
.with_poll_sequence([Ok(make_dump_task(DumpStatus::Completed))])
.with_delete_result(Ok(()))
.with_download_bytes(b"new".to_vec());
let mut renderer = RecordingRenderer::new();
let mut reporter = RecordingProgressReporter::new();
run_impl(
&args,
&cfg,
&client,
&mut renderer,
&mut reporter,
Some("tok".to_string()),
&no_op_sleeper(),
fixed_now(),
None,
dir.path(),
)
.unwrap();
let outcome = renderer.dump_outcome.unwrap();
assert!(!outcome.reused, "replace always produces reused:false");
let log = client.call_log();
assert_eq!(log[0], CallRecord::Resolve, "first call must be Resolve");
assert_eq!(
log[1],
CallRecord::Create {
project_iri: "http://rdfh.ch/projects/0001".to_string(),
},
"second call must be Create"
);
assert_eq!(
log[2],
CallRecord::Status("http://rdfh.ch/projects/0001".to_string()),
"third call must be Status"
);
assert_eq!(
log[3],
CallRecord::Delete("http://rdfh.ch/projects/0001".to_string()),
"fourth call must be Delete"
);
assert_eq!(
log[4],
CallRecord::Create {
project_iri: "http://rdfh.ch/projects/0001".to_string(),
},
"fifth call must be Create (2nd)"
);
assert_eq!(log[5], CallRecord::Poll, "sixth call must be Poll");
assert_eq!(
log[6],
CallRecord::Download,
"seventh call must be Download"
);
assert_eq!(log.len(), 7, "must be exactly 7 calls");
assert!(
reporter
.events
.contains(&EventRecord::Deleting("old-id".into())),
"must report Deleting for the old dump"
);
assert!(
reporter
.events
.contains(&EventRecord::Triggered("new-id".into())),
"must report Triggered for the new dump; events: {:?}",
reporter.events
);
}
#[test]
fn replace_failed_existing_deletes_then_recreates() {
let dir = TempDir::new().unwrap();
let (mut args, cfg) = make_args(&dir);
args.replace = true;
let failed_task = DumpTask {
id: "failed-old-id".into(),
status: DumpStatus::Failed,
error_message: Some("ran out of space".into()),
created_at: None,
};
let task2 = DumpTask {
id: "new-id-2".into(),
status: DumpStatus::InProgress,
error_message: None,
created_at: None,
};
let client = MockDspClient::new()
.with_resolve_project(Ok(make_project_ref()))
.with_create_exists("failed-old-id")
.with_create_sequence([Ok(CreateDumpOutcome::Created(task2))])
.with_status_sequence([Ok(failed_task)])
.with_poll_sequence([Ok(make_dump_task(DumpStatus::Completed))])
.with_delete_result(Ok(()))
.with_download_bytes(b"new".to_vec());
let mut renderer = RecordingRenderer::new();
let mut reporter = RecordingProgressReporter::new();
run_impl(
&args,
&cfg,
&client,
&mut renderer,
&mut reporter,
Some("tok".to_string()),
&no_op_sleeper(),
fixed_now(),
None,
dir.path(),
)
.unwrap();
let outcome = renderer.dump_outcome.unwrap();
assert!(!outcome.reused);
}
#[test]
fn replace_recreate_race_returns_conflict() {
let dir = TempDir::new().unwrap();
let (mut args, cfg) = make_args(&dir);
args.replace = true;
let existing_task = DumpTask {
id: "race-id".into(),
status: DumpStatus::Completed,
error_message: None,
created_at: None,
};
let client = MockDspClient::new()
.with_resolve_project(Ok(make_project_ref()))
.with_create_exists("race-id")
.with_create_sequence([Ok(CreateDumpOutcome::Exists {
id: "race-id-2".into(),
})])
.with_status_sequence([Ok(existing_task)])
.with_delete_result(Ok(()));
let mut renderer = RecordingRenderer::new();
let mut reporter = RecordingProgressReporter::new();
let err = run_impl(
&args,
&cfg,
&client,
&mut renderer,
&mut reporter,
Some("tok".to_string()),
&no_op_sleeper(),
fixed_now(),
None,
dir.path(),
)
.unwrap_err();
assert!(
matches!(err, Diagnostic::Conflict(_)),
"recreate race must yield Conflict"
);
let msg = err.to_string();
assert!(
msg.contains("recreated"),
"message must mention recreation: {msg}"
);
}
#[test]
fn replace_in_progress_returns_conflict() {
let dir = TempDir::new().unwrap();
let (mut args, cfg) = make_args(&dir);
args.replace = true;
let in_progress_task = DumpTask {
id: "ip-id".into(),
status: DumpStatus::InProgress,
error_message: None,
created_at: None,
};
let client = MockDspClient::new()
.with_resolve_project(Ok(make_project_ref()))
.with_create_exists("ip-id")
.with_status_sequence([Ok(in_progress_task)]);
let mut renderer = RecordingRenderer::new();
let mut reporter = RecordingProgressReporter::new();
let err = run_impl(
&args,
&cfg,
&client,
&mut renderer,
&mut reporter,
Some("tok".to_string()),
&no_op_sleeper(),
fixed_now(),
None,
dir.path(),
)
.unwrap_err();
assert!(
matches!(err, Diagnostic::Conflict(_)),
"in-progress existing dump must block replace"
);
let msg = err.to_string();
assert!(
msg.contains("in progress"),
"message must mention in-progress state: {msg}"
);
assert_eq!(
*client.delete_calls.borrow(),
0,
"delete must not be called when in-progress"
);
}
#[test]
fn delete_completed_deletes_without_downloading() {
let dir = TempDir::new().unwrap();
let (mut args, cfg) = make_args(&dir);
args.delete = true;
let completed_task = DumpTask {
id: "del-id".into(),
status: DumpStatus::Completed,
error_message: None,
created_at: None,
};
let client = MockDspClient::new()
.with_resolve_project(Ok(make_project_ref()))
.with_create_exists("del-id")
.with_status_sequence([Ok(completed_task)])
.with_delete_result(Ok(()));
let mut renderer = RecordingRenderer::new();
let mut reporter = RecordingProgressReporter::new();
run_impl(
&args,
&cfg,
&client,
&mut renderer,
&mut reporter,
Some("tok".to_string()),
&no_op_sleeper(),
fixed_now(),
None,
dir.path(),
)
.unwrap();
assert_eq!(
*client.download_calls.borrow(),
0,
"delete must not download"
);
assert_eq!(*client.delete_calls.borrow(), 1);
let del_outcome = renderer
.dump_deleted_outcome
.expect("project_dump_deleted must have been called");
assert!(del_outcome.deleted);
assert!(del_outcome.note.is_none());
assert!(
renderer.dump_outcome.is_none(),
"project_dump must not be called in delete mode"
);
assert!(
reporter
.events
.contains(&EventRecord::Deleting("del-id".into()))
);
let log = client.call_log();
assert_eq!(log[0], CallRecord::Resolve);
assert_eq!(
log[1],
CallRecord::Create {
project_iri: "http://rdfh.ch/projects/0001".to_string(),
}
);
assert_eq!(
log[2],
CallRecord::Status("http://rdfh.ch/projects/0001".to_string())
);
assert_eq!(
log[3],
CallRecord::Delete("http://rdfh.ch/projects/0001".to_string())
);
assert_eq!(log.len(), 4, "must be exactly 4 calls");
}
#[test]
fn delete_failed_deletes_without_downloading() {
let dir = TempDir::new().unwrap();
let (mut args, cfg) = make_args(&dir);
args.delete = true;
let failed_task = DumpTask {
id: "del-failed-id".into(),
status: DumpStatus::Failed,
error_message: Some("disk full".into()),
created_at: None,
};
let client = MockDspClient::new()
.with_resolve_project(Ok(make_project_ref()))
.with_create_exists("del-failed-id")
.with_status_sequence([Ok(failed_task)])
.with_delete_result(Ok(()));
let mut renderer = RecordingRenderer::new();
let mut reporter = RecordingProgressReporter::new();
run_impl(
&args,
&cfg,
&client,
&mut renderer,
&mut reporter,
Some("tok".to_string()),
&no_op_sleeper(),
fixed_now(),
None,
dir.path(),
)
.unwrap();
assert_eq!(
*client.download_calls.borrow(),
0,
"delete must not download even for a failed dump"
);
assert_eq!(
*client.delete_calls.borrow(),
1,
"delete must be called for a failed dump"
);
let del_outcome = renderer
.dump_deleted_outcome
.expect("project_dump_deleted must have been called");
assert!(del_outcome.deleted, "deleted must be true for failed dump");
assert!(del_outcome.note.is_none());
assert!(
renderer.dump_outcome.is_none(),
"project_dump must not be called in delete mode"
);
let log = client.call_log();
assert_eq!(log[0], CallRecord::Resolve);
assert_eq!(
log[1],
CallRecord::Create {
project_iri: "http://rdfh.ch/projects/0001".to_string(),
}
);
assert_eq!(
log[2],
CallRecord::Status("http://rdfh.ch/projects/0001".to_string())
);
assert_eq!(
log[3],
CallRecord::Delete("http://rdfh.ch/projects/0001".to_string())
);
assert_eq!(log.len(), 4, "must be exactly 4 calls");
}
#[test]
fn delete_in_progress_returns_conflict() {
let dir = TempDir::new().unwrap();
let (mut args, cfg) = make_args(&dir);
args.delete = true;
let in_progress_task = DumpTask {
id: "del-ip-id".into(),
status: DumpStatus::InProgress,
error_message: None,
created_at: None,
};
let client = MockDspClient::new()
.with_resolve_project(Ok(make_project_ref()))
.with_create_exists("del-ip-id")
.with_status_sequence([Ok(in_progress_task)]);
let mut renderer = RecordingRenderer::new();
let mut reporter = RecordingProgressReporter::new();
let err = run_impl(
&args,
&cfg,
&client,
&mut renderer,
&mut reporter,
Some("tok".to_string()),
&no_op_sleeper(),
fixed_now(),
None,
dir.path(),
)
.unwrap_err();
assert!(
matches!(err, Diagnostic::Conflict(_)),
"in-progress dump must block delete"
);
let msg = err.to_string();
assert!(
msg.contains("in progress"),
"message must mention in-progress: {msg}"
);
assert_eq!(
*client.delete_calls.borrow(),
0,
"delete must not be called"
);
}
#[test]
fn delete_none_probe_created_reports_probe_and_exits_ok() {
let dir = TempDir::new().unwrap();
let (mut args, cfg) = make_args(&dir);
args.delete = true;
let client = MockDspClient::new()
.with_resolve_project(Ok(make_project_ref()))
.with_create_dump(Ok(created_task(DumpStatus::InProgress)));
let mut renderer = RecordingRenderer::new();
let mut reporter = RecordingProgressReporter::new();
run_impl(
&args,
&cfg,
&client,
&mut renderer,
&mut reporter,
Some("tok".to_string()),
&no_op_sleeper(),
fixed_now(),
None,
dir.path(),
)
.unwrap();
assert!(renderer.dump_outcome.is_none());
let del_outcome = renderer
.dump_deleted_outcome
.expect("project_dump_deleted must be called");
assert!(
!del_outcome.deleted,
"deleted must be false (probe, not real delete)"
);
let note = del_outcome.note.expect("note must be set for probe case");
assert!(
note.contains("dump-id-42"),
"note must mention the probe id: {note}"
);
assert!(
reporter
.events
.contains(&EventRecord::ProbeCreated("dump-id-42".into())),
"must report ProbeCreated; events: {:?}",
reporter.events
);
assert_eq!(*client.download_calls.borrow(), 0);
assert_eq!(*client.delete_calls.borrow(), 0);
}
fn foreign_iri() -> &'static str {
"http://rdfh.ch/projects/0002"
}
#[test]
fn default_exists_for_other_project_returns_conflict_no_server_calls() {
let dir = TempDir::new().unwrap();
let (args, cfg) = make_args(&dir);
let client = MockDspClient::new()
.with_resolve_project(Ok(make_project_ref()))
.with_create_exists_other_project("foreign-dump-id", foreign_iri());
let mut renderer = RecordingRenderer::new();
let mut reporter = RecordingProgressReporter::new();
let err = run_impl(
&args,
&cfg,
&client,
&mut renderer,
&mut reporter,
Some("tok".to_string()),
&no_op_sleeper(),
fixed_now(),
None,
dir.path(),
)
.unwrap_err();
assert!(
matches!(err, Diagnostic::Conflict(_)),
"Default + ExistsForOtherProject must yield Conflict, got {err:?}"
);
let msg = err.to_string();
assert!(
msg.contains(foreign_iri()),
"Conflict message must name the foreign IRI: {msg}"
);
assert!(
msg.contains("--replace --discard-other-project"),
"Conflict message must hint at --replace --discard-other-project: {msg}"
);
let log = client.call_log();
assert_eq!(log[0], CallRecord::Resolve);
assert_eq!(
log[1],
CallRecord::Create {
project_iri: "http://rdfh.ch/projects/0001".to_string(),
}
);
assert_eq!(
log.len(),
2,
"must be exactly 2 calls (no status/delete/download)"
);
}
#[test]
fn replace_exists_for_other_project_without_flag_returns_conflict_no_status_delete() {
let dir = TempDir::new().unwrap();
let (mut args, cfg) = make_args(&dir);
args.replace = true;
let client = MockDspClient::new()
.with_resolve_project(Ok(make_project_ref()))
.with_create_exists_other_project("foreign-dump-id", foreign_iri());
let mut renderer = RecordingRenderer::new();
let mut reporter = RecordingProgressReporter::new();
let err = run_impl(
&args,
&cfg,
&client,
&mut renderer,
&mut reporter,
Some("tok".to_string()),
&no_op_sleeper(),
fixed_now(),
None,
dir.path(),
)
.unwrap_err();
assert!(
matches!(err, Diagnostic::Conflict(_)),
"Replace + ExistsForOtherProject without flag must yield Conflict, got {err:?}"
);
let msg = err.to_string();
assert!(
msg.contains(foreign_iri()),
"Conflict message must name the foreign IRI: {msg}"
);
assert!(
msg.contains("--replace --discard-other-project"),
"Conflict message must hint at the flag: {msg}"
);
let log = client.call_log();
assert_eq!(log[0], CallRecord::Resolve);
assert_eq!(
log[1],
CallRecord::Create {
project_iri: "http://rdfh.ch/projects/0001".to_string(),
}
);
assert_eq!(log.len(), 2, "must be exactly 2 calls (no status/delete)");
}
#[test]
fn replace_exists_for_other_project_with_flag_foreign_completed_discards_and_recreates() {
let dir = TempDir::new().unwrap();
let (mut args, cfg) = make_args(&dir);
args.replace = true;
args.discard_other_project = true;
let foreign_task = DumpTask {
id: "foreign-dump-id".into(),
status: DumpStatus::Completed,
error_message: None,
created_at: None,
};
let new_task = DumpTask {
id: "new-dump-id".into(),
status: DumpStatus::InProgress,
error_message: None,
created_at: None,
};
let client = MockDspClient::new()
.with_resolve_project(Ok(make_project_ref()))
.with_create_exists_other_project("foreign-dump-id", foreign_iri())
.with_status_sequence([Ok(foreign_task)])
.with_create_sequence([Ok(CreateDumpOutcome::Created(new_task))])
.with_delete_result(Ok(()))
.with_poll_sequence([Ok(make_dump_task(DumpStatus::Completed))])
.with_download_bytes(b"dump-data".to_vec());
let mut renderer = RecordingRenderer::new();
let mut reporter = RecordingProgressReporter::new();
run_impl(
&args,
&cfg,
&client,
&mut renderer,
&mut reporter,
Some("tok".to_string()),
&no_op_sleeper(),
fixed_now(),
None,
dir.path(),
)
.unwrap();
assert!(
reporter
.events
.contains(&EventRecord::DiscardingOtherProjectDump {
id: "foreign-dump-id".into(),
project_iri: foreign_iri().to_string(),
}),
"must report DiscardingOtherProjectDump; events: {:?}",
reporter.events
);
let log = client.call_log();
assert_eq!(log[0], CallRecord::Resolve, "first must be Resolve");
assert_eq!(
log[1],
CallRecord::Create {
project_iri: make_project_ref().iri,
},
"second must be Create with REQUESTED project IRI (initial probe)"
);
assert_eq!(
log[2],
CallRecord::Status(foreign_iri().to_string()),
"third must be Status with FOREIGN iri"
);
assert_eq!(
log[3],
CallRecord::Delete(foreign_iri().to_string()),
"fourth must be Delete with FOREIGN iri"
);
assert_eq!(
log[4],
CallRecord::Create {
project_iri: make_project_ref().iri,
},
"fifth must be Create with REQUESTED project IRI (recreate after discard)"
);
assert_eq!(log[5], CallRecord::Poll, "sixth must be Poll");
assert_eq!(log[6], CallRecord::Download, "seventh must be Download");
assert_eq!(log.len(), 7, "must be exactly 7 calls");
assert!(
renderer.dump_outcome.is_some(),
"project_dump must be called after successful discard+recreate"
);
}
#[test]
fn replace_exists_for_other_project_with_flag_foreign_failed_discards_and_recreates() {
let dir = TempDir::new().unwrap();
let (mut args, cfg) = make_args(&dir);
args.replace = true;
args.discard_other_project = true;
let foreign_task = DumpTask {
id: "foreign-dump-id".into(),
status: DumpStatus::Failed,
error_message: Some("out of disk space".into()),
created_at: None,
};
let new_task = DumpTask {
id: "new-dump-id".into(),
status: DumpStatus::InProgress,
error_message: None,
created_at: None,
};
let client = MockDspClient::new()
.with_resolve_project(Ok(make_project_ref()))
.with_create_exists_other_project("foreign-dump-id", foreign_iri())
.with_status_sequence([Ok(foreign_task)])
.with_create_sequence([Ok(CreateDumpOutcome::Created(new_task))])
.with_delete_result(Ok(()))
.with_poll_sequence([Ok(make_dump_task(DumpStatus::Completed))])
.with_download_bytes(b"dump-data".to_vec());
let mut renderer = RecordingRenderer::new();
let mut reporter = RecordingProgressReporter::new();
run_impl(
&args,
&cfg,
&client,
&mut renderer,
&mut reporter,
Some("tok".to_string()),
&no_op_sleeper(),
fixed_now(),
None,
dir.path(),
)
.unwrap();
assert!(
reporter
.events
.contains(&EventRecord::DiscardingOtherProjectDump {
id: "foreign-dump-id".into(),
project_iri: foreign_iri().to_string(),
}),
"must report DiscardingOtherProjectDump; events: {:?}",
reporter.events
);
let log = client.call_log();
assert_eq!(log[0], CallRecord::Resolve, "first must be Resolve");
assert_eq!(
log[1],
CallRecord::Create {
project_iri: make_project_ref().iri,
},
"second must be Create with REQUESTED project IRI (initial probe)"
);
assert_eq!(
log[2],
CallRecord::Status(foreign_iri().to_string()),
"third must be Status with FOREIGN iri"
);
assert_eq!(
log[3],
CallRecord::Delete(foreign_iri().to_string()),
"fourth must be Delete with FOREIGN iri"
);
assert_eq!(
log[4],
CallRecord::Create {
project_iri: make_project_ref().iri,
},
"fifth must be Create with REQUESTED project IRI (recreate after discard)"
);
assert_eq!(log[5], CallRecord::Poll, "sixth must be Poll");
assert_eq!(log[6], CallRecord::Download, "seventh must be Download");
assert_eq!(log.len(), 7, "must be exactly 7 calls");
assert!(
renderer.dump_outcome.is_some(),
"project_dump must be called after successful discard+recreate"
);
}
#[test]
fn replace_exists_for_other_project_with_flag_foreign_in_progress_returns_conflict() {
let dir = TempDir::new().unwrap();
let (mut args, cfg) = make_args(&dir);
args.replace = true;
args.discard_other_project = true;
let foreign_task = DumpTask {
id: "foreign-dump-id".into(),
status: DumpStatus::InProgress,
error_message: None,
created_at: None,
};
let client = MockDspClient::new()
.with_resolve_project(Ok(make_project_ref()))
.with_create_exists_other_project("foreign-dump-id", foreign_iri())
.with_status_sequence([Ok(foreign_task)]);
let mut renderer = RecordingRenderer::new();
let mut reporter = RecordingProgressReporter::new();
let err = run_impl(
&args,
&cfg,
&client,
&mut renderer,
&mut reporter,
Some("tok".to_string()),
&no_op_sleeper(),
fixed_now(),
None,
dir.path(),
)
.unwrap_err();
assert!(
matches!(err, Diagnostic::Conflict(_)),
"foreign InProgress must yield Conflict, got {err:?}"
);
let msg = err.to_string();
assert!(
msg.contains("in progress"),
"message must mention in progress: {msg}"
);
assert!(
msg.contains(foreign_iri()),
"message must name the foreign IRI: {msg}"
);
let log = client.call_log();
assert_eq!(
log[2],
CallRecord::Status(foreign_iri().to_string()),
"status must use FOREIGN iri"
);
assert_eq!(
*client.delete_calls.borrow(),
0,
"delete must not be called for in-progress foreign dump"
);
}
#[test]
fn delete_exists_for_other_project_is_noop_no_status_delete_calls() {
let dir = TempDir::new().unwrap();
let (mut args, cfg) = make_args(&dir);
args.delete = true;
let client = MockDspClient::new()
.with_resolve_project(Ok(make_project_ref()))
.with_create_exists_other_project("foreign-dump-id", foreign_iri());
let mut renderer = RecordingRenderer::new();
let mut reporter = RecordingProgressReporter::new();
run_impl(
&args,
&cfg,
&client,
&mut renderer,
&mut reporter,
Some("tok".to_string()),
&no_op_sleeper(),
fixed_now(),
None,
dir.path(),
)
.unwrap();
assert!(renderer.dump_outcome.is_none());
let del_outcome = renderer
.dump_deleted_outcome
.expect("project_dump_deleted must have been called");
assert!(
!del_outcome.deleted,
"deleted must be false for foreign-slot no-op"
);
let note = del_outcome
.note
.expect("note must be set for foreign-slot case");
assert!(
note.contains(foreign_iri()),
"note must name the foreign project IRI: {note}"
);
let log = client.call_log();
assert_eq!(log[0], CallRecord::Resolve);
assert_eq!(
log[1],
CallRecord::Create {
project_iri: "http://rdfh.ch/projects/0001".to_string(),
}
);
assert_eq!(log.len(), 2, "must be exactly 2 calls (no status/delete)");
assert_eq!(
*client.delete_calls.borrow(),
0,
"delete must not be called for foreign-slot no-op"
);
}
const LIST_SERVER: &str = "https://api.test.dasch.swiss";
fn make_list_args(filter: Option<&str>) -> ProjectListArgs {
ProjectListArgs {
server: Some(LIST_SERVER.to_string()),
filter: filter.map(str::to_string),
format: FormatArgs {
format: Format::Prose,
json: false,
lines: false,
columns: None,
no_header: false,
header_only: false,
},
}
}
fn make_list_cfg() -> Config {
Config {
server: LIST_SERVER.to_string(),
}
}
fn make_project(shortcode: &str, shortname: &str, longname: Option<&str>) -> Project {
Project {
iri: format!("http://rdfh.ch/projects/{shortcode}"),
shortcode: shortcode.to_string(),
shortname: shortname.to_string(),
longname: longname.map(str::to_string),
status: ProjectStatus::Active,
data_models: 2,
}
}
fn two_project_list() -> Vec<Project> {
vec![
make_project("0002", "images", None),
make_project("0001", "anything", Some("Anything Project")),
]
}
#[test]
fn list_anonymous_no_token_no_cache() {
let dir = TempDir::new().unwrap();
let cache_path = dir.path().join("auth.toml");
let args = make_list_args(None);
let cfg = make_list_cfg();
let client = MockDspClient::new().with_list_projects_result(Ok(two_project_list()));
let mut renderer = RecordingRenderer::new();
run_list_impl(&args, &cfg, &client, &mut renderer, None, Some(&cache_path))
.expect("must succeed anonymously");
let meta = renderer.projects_meta.unwrap();
assert_eq!(meta.auth_state, "anonymous");
let recorded_token = client.list_projects_token();
assert_eq!(
recorded_token, None,
"must call list_projects with token=None when no credentials"
);
let (items, total, filter) = renderer.projects_view.unwrap();
assert_eq!(total, 2);
assert_eq!(items.len(), 2);
assert!(filter.is_none());
}
#[test]
fn list_corrupt_cache_falls_back_to_anonymous() {
let dir = TempDir::new().unwrap();
let cache_path = dir.path().join("auth.toml");
std::fs::write(&cache_path, b"NOT VALID TOML }{").unwrap();
let args = make_list_args(None);
let cfg = make_list_cfg();
let client = MockDspClient::new().with_list_projects_result(Ok(two_project_list()));
let mut renderer = RecordingRenderer::new();
run_list_impl(&args, &cfg, &client, &mut renderer, None, Some(&cache_path))
.expect("corrupt cache must not cause an error for list (auth-optional)");
let meta = renderer.projects_meta.unwrap();
assert_eq!(meta.auth_state, "anonymous");
assert_eq!(client.list_projects_token(), None);
}
#[test]
fn list_env_token_authenticated_via_dsp_token() {
let dir = TempDir::new().unwrap();
let cache_path = dir.path().join("auth.toml");
let args = make_list_args(None);
let cfg = make_list_cfg();
let client = MockDspClient::new().with_list_projects_result(Ok(two_project_list()));
let mut renderer = RecordingRenderer::new();
run_list_impl(
&args,
&cfg,
&client,
&mut renderer,
Some("my-env-token".to_string()),
Some(&cache_path),
)
.expect("must succeed with env token");
let meta = renderer.projects_meta.unwrap();
assert_eq!(meta.auth_state, "authenticated via DSP_TOKEN");
let recorded_token = client.list_projects_token();
assert_eq!(
recorded_token,
Some("my-env-token".to_string()),
"token must be forwarded to list_projects"
);
}
#[test]
fn list_cache_token_with_user() {
let dir = TempDir::new().unwrap();
let cache_path = dir.path().join("auth.toml");
let mut cache = AuthCache::default();
cache.set_entry(
LIST_SERVER,
ServerEntry {
token: "cache-token-xyz".to_string(),
user: Some("alice@example.com".to_string()),
acquired_at: None,
expires_at: None,
},
);
cache.save_to(&cache_path).unwrap();
let args = make_list_args(None);
let cfg = make_list_cfg();
let client = MockDspClient::new().with_list_projects_result(Ok(two_project_list()));
let mut renderer = RecordingRenderer::new();
run_list_impl(&args, &cfg, &client, &mut renderer, None, Some(&cache_path))
.expect("must succeed with cache token");
let meta = renderer.projects_meta.unwrap();
assert_eq!(meta.auth_state, "authenticated as alice@example.com");
let recorded_token = client.list_projects_token();
assert_eq!(
recorded_token,
Some("cache-token-xyz".to_string()),
"cache token must be forwarded to list_projects"
);
}
#[test]
fn list_filter_matches_subset_case_insensitively() {
let dir = TempDir::new().unwrap();
let cache_path = dir.path().join("auth.toml");
let args = make_list_args(Some("ANYTH"));
let cfg = make_list_cfg();
let client = MockDspClient::new().with_list_projects_result(Ok(two_project_list()));
let mut renderer = RecordingRenderer::new();
run_list_impl(&args, &cfg, &client, &mut renderer, None, Some(&cache_path))
.expect("filter must not cause an error");
let (items, total, filter) = renderer.projects_view.unwrap();
assert_eq!(total, 2, "total must be pre-filter count");
assert_eq!(items.len(), 1, "only one project matches 'ANYTH'");
assert_eq!(items[0].shortname, "anything");
assert_eq!(filter.as_deref(), Some("ANYTH"));
}
#[test]
fn list_filter_no_match_returns_empty_items() {
let dir = TempDir::new().unwrap();
let cache_path = dir.path().join("auth.toml");
let args = make_list_args(Some("zzz-no-match-zzz"));
let cfg = make_list_cfg();
let client = MockDspClient::new().with_list_projects_result(Ok(two_project_list()));
let mut renderer = RecordingRenderer::new();
run_list_impl(&args, &cfg, &client, &mut renderer, None, Some(&cache_path))
.expect("no-match filter must not be an error");
let (items, total, _) = renderer.projects_view.unwrap();
assert_eq!(total, 2, "total must still show pre-filter count");
assert!(
items.is_empty(),
"items must be empty when filter matches nothing"
);
}
#[test]
fn list_results_sorted_by_shortcode_ascending() {
let dir = TempDir::new().unwrap();
let cache_path = dir.path().join("auth.toml");
let args = make_list_args(None);
let cfg = make_list_cfg();
let unsorted = vec![
make_project("0003", "proj-c", None),
make_project("0001", "proj-a", None),
make_project("0002", "proj-b", None),
];
let client = MockDspClient::new().with_list_projects_result(Ok(unsorted));
let mut renderer = RecordingRenderer::new();
run_list_impl(&args, &cfg, &client, &mut renderer, None, Some(&cache_path))
.expect("sort must not error");
let (items, _, _) = renderer.projects_view.unwrap();
let shortcodes: Vec<&str> = items.iter().map(|p| p.shortcode.as_str()).collect();
assert_eq!(shortcodes, vec!["0001", "0002", "0003"]);
}
#[test]
fn list_filter_matches_longname_case_insensitively() {
let dir = TempDir::new().unwrap();
let cache_path = dir.path().join("auth.toml");
let args = make_list_args(Some("ANYTHING PROJECT"));
let cfg = make_list_cfg();
let client = MockDspClient::new().with_list_projects_result(Ok(two_project_list()));
let mut renderer = RecordingRenderer::new();
run_list_impl(&args, &cfg, &client, &mut renderer, None, Some(&cache_path))
.expect("longname filter must not error");
let (items, total, _) = renderer.projects_view.unwrap();
assert_eq!(total, 2);
assert_eq!(items.len(), 1);
assert_eq!(items[0].shortname, "anything");
}
#[test]
fn list_filter_matches_shortcode() {
let dir = TempDir::new().unwrap();
let cache_path = dir.path().join("auth.toml");
let args = make_list_args(Some("0002"));
let cfg = make_list_cfg();
let client = MockDspClient::new().with_list_projects_result(Ok(two_project_list()));
let mut renderer = RecordingRenderer::new();
run_list_impl(&args, &cfg, &client, &mut renderer, None, Some(&cache_path))
.expect("shortcode filter must not error");
let (items, total, _) = renderer.projects_view.unwrap();
assert_eq!(total, 2);
assert_eq!(items.len(), 1);
assert_eq!(items[0].shortcode, "0002");
}
#[test]
fn config_resolve_none_returns_usage_error() {
let err = crate::config::Config::resolve(None).unwrap_err();
assert!(
matches!(err, Diagnostic::Usage(_)),
"expected Usage diagnostic for missing server, got {err:?}"
);
let msg = err.to_string();
assert!(
msg.contains("--server") || msg.contains("DSP_SERVER"),
"{msg}"
);
}
#[test]
fn list_token_assertion_is_real() {
let dir = TempDir::new().unwrap();
let cache_path = dir.path().join("auth.toml");
let args = make_list_args(None);
let cfg = make_list_cfg();
let client = MockDspClient::new().with_list_projects_result(Ok(two_project_list()));
let mut renderer = RecordingRenderer::new();
run_list_impl(
&args,
&cfg,
&client,
&mut renderer,
Some("my-env-token".to_string()),
Some(&cache_path),
)
.unwrap();
let recorded = client.list_projects_token();
assert_eq!(recorded, Some("my-env-token".to_string()));
assert_ne!(
recorded,
Some("wrong-token".to_string()),
"token assertion must be real: wrong token should not match"
);
}
const DESCRIBE_SERVER: &str = "https://api.test.dasch.swiss";
fn make_describe_args(project: Option<&str>) -> ProjectDescribeArgs {
ProjectDescribeArgs {
server: Some(DESCRIBE_SERVER.to_string()),
project: project.map(str::to_string),
format: FormatArgs {
format: Format::Prose,
json: false,
lines: false,
columns: None,
no_header: false,
header_only: false,
},
}
}
fn make_describe_cfg() -> Config {
Config {
server: DESCRIBE_SERVER.to_string(),
}
}
fn make_project_detail() -> ProjectDetail {
ProjectDetail {
iri: "http://rdfh.ch/projects/yTerZGyxjZVqFMNNKXCDPF".to_string(),
shortcode: "0801".to_string(),
shortname: "beol".to_string(),
longname: Some("Bernoulli-Euler Online".to_string()),
status: ProjectStatus::Active,
description: vec![ProjectDescription {
value: "A project about Bernoulli and Euler.".to_string(),
language: Some("en".to_string()),
}],
keywords: vec!["Bernoulli".to_string(), "Euler".to_string()],
data_models: vec![
DataModelSummary {
name: "beol".to_string(),
iri: "http://api.dasch.swiss/ontology/0801/beol/v2".to_string(),
},
DataModelSummary {
name: "biblio".to_string(),
iri: "http://api.dasch.swiss/ontology/0801/biblio/v2".to_string(),
},
],
}
}
#[test]
fn describe_success_records_detail_and_meta() {
let dir = TempDir::new().unwrap();
let cache_path = dir.path().join("auth.toml");
let args = make_describe_args(Some("0801"));
let cfg = make_describe_cfg();
let detail = make_project_detail();
let client = MockDspClient::new().with_describe_project_result(Ok(detail.clone()));
let mut renderer = RecordingRenderer::new();
run_describe_impl(&args, &cfg, &client, &mut renderer, None, Some(&cache_path))
.expect("describe must succeed");
let recorded_detail = renderer.describe_detail.unwrap();
assert_eq!(
recorded_detail, detail,
"renderer must receive the exact ProjectDetail"
);
let meta = renderer.describe_meta.unwrap();
assert_eq!(meta.server_label, DESCRIBE_SERVER);
assert_eq!(meta.auth_state, "anonymous");
assert!(meta.filter_warning.is_none());
}
#[test]
fn describe_forwards_project_and_token_to_client() {
let dir = TempDir::new().unwrap();
let cache_path = dir.path().join("auth.toml");
let args = make_describe_args(Some("0801"));
let cfg = make_describe_cfg();
let client = MockDspClient::new().with_describe_project_result(Ok(make_project_detail()));
let mut renderer = RecordingRenderer::new();
run_describe_impl(
&args,
&cfg,
&client,
&mut renderer,
Some("my-env-token".to_string()),
Some(&cache_path),
)
.expect("describe must succeed");
let (project_arg, token_arg) = client.describe_project_call();
assert_eq!(project_arg, "0801", "project argument must be forwarded");
assert_eq!(
token_arg,
Some("my-env-token".to_string()),
"env token must be forwarded to describe_project"
);
}
#[test]
fn describe_not_found_propagates_error() {
let dir = TempDir::new().unwrap();
let cache_path = dir.path().join("auth.toml");
let args = make_describe_args(Some("9999"));
let cfg = make_describe_cfg();
let client = MockDspClient::new().with_describe_project_result(Err(Diagnostic::NotFound(
"project '9999' not found".to_string(),
)));
let mut renderer = RecordingRenderer::new();
let err = run_describe_impl(&args, &cfg, &client, &mut renderer, None, Some(&cache_path))
.unwrap_err();
assert!(
matches!(err, Diagnostic::NotFound(_)),
"expected NotFound, got {err:?}"
);
}
#[test]
fn describe_missing_project_returns_usage_error() {
let dir = TempDir::new().unwrap();
let cache_path = dir.path().join("auth.toml");
let args = make_describe_args(None); let cfg = make_describe_cfg();
let client = MockDspClient::new();
let mut renderer = RecordingRenderer::new();
let err = run_describe_impl(&args, &cfg, &client, &mut renderer, None, Some(&cache_path))
.unwrap_err();
assert!(
matches!(err, Diagnostic::Usage(_)),
"expected Usage diagnostic for missing --project, got {err:?}"
);
let msg = err.to_string();
assert!(
msg.contains("--project"),
"--project must appear in the usage message: {msg}"
);
assert!(
renderer.describe_detail.is_none(),
"renderer must not be called when --project is missing"
);
assert!(
!client.describe_project_was_called(),
"client.describe_project must not be called when --project is missing"
);
}
#[test]
fn describe_anonymous_no_token_no_cache() {
let dir = TempDir::new().unwrap();
let cache_path = dir.path().join("auth.toml");
let args = make_describe_args(Some("0801"));
let cfg = make_describe_cfg();
let client = MockDspClient::new().with_describe_project_result(Ok(make_project_detail()));
let mut renderer = RecordingRenderer::new();
run_describe_impl(&args, &cfg, &client, &mut renderer, None, Some(&cache_path))
.expect("must succeed anonymously");
let meta = renderer.describe_meta.unwrap();
assert_eq!(meta.auth_state, "anonymous");
let (_, token_arg) = client.describe_project_call();
assert_eq!(token_arg, None, "no token must be forwarded when anonymous");
}
#[test]
fn describe_env_token_authenticated_via_dsp_token() {
let dir = TempDir::new().unwrap();
let cache_path = dir.path().join("auth.toml");
let args = make_describe_args(Some("0801"));
let cfg = make_describe_cfg();
let client = MockDspClient::new().with_describe_project_result(Ok(make_project_detail()));
let mut renderer = RecordingRenderer::new();
run_describe_impl(
&args,
&cfg,
&client,
&mut renderer,
Some("env-token-xyz".to_string()),
Some(&cache_path),
)
.expect("must succeed with env token");
let meta = renderer.describe_meta.unwrap();
assert_eq!(meta.auth_state, "authenticated via DSP_TOKEN");
let (_, token_arg) = client.describe_project_call();
assert_eq!(
token_arg,
Some("env-token-xyz".to_string()),
"env token must be forwarded to describe_project"
);
}
#[test]
fn describe_cache_token_with_user() {
let dir = TempDir::new().unwrap();
let cache_path = dir.path().join("auth.toml");
let mut cache = AuthCache::default();
cache.set_entry(
DESCRIBE_SERVER,
ServerEntry {
token: "cache-token-abc".to_string(),
user: Some("bob@example.com".to_string()),
acquired_at: None,
expires_at: None,
},
);
cache.save_to(&cache_path).unwrap();
let args = make_describe_args(Some("0801"));
let cfg = make_describe_cfg();
let client = MockDspClient::new().with_describe_project_result(Ok(make_project_detail()));
let mut renderer = RecordingRenderer::new();
run_describe_impl(&args, &cfg, &client, &mut renderer, None, Some(&cache_path))
.expect("must succeed with cache token");
let meta = renderer.describe_meta.unwrap();
assert_eq!(meta.auth_state, "authenticated as bob@example.com");
let (_, token_arg) = client.describe_project_call();
assert_eq!(
token_arg,
Some("cache-token-abc".to_string()),
"cache token must be forwarded to describe_project"
);
}
#[test]
fn describe_corrupt_cache_falls_back_to_anonymous() {
let dir = TempDir::new().unwrap();
let cache_path = dir.path().join("auth.toml");
std::fs::write(&cache_path, b"NOT VALID TOML }{").unwrap();
let args = make_describe_args(Some("0801"));
let cfg = make_describe_cfg();
let client = MockDspClient::new().with_describe_project_result(Ok(make_project_detail()));
let mut renderer = RecordingRenderer::new();
run_describe_impl(&args, &cfg, &client, &mut renderer, None, Some(&cache_path))
.expect("corrupt cache must not cause an error for describe (auth-optional)");
let meta = renderer.describe_meta.unwrap();
assert_eq!(meta.auth_state, "anonymous");
let (_, token_arg) = client.describe_project_call();
assert_eq!(token_arg, None, "no token must be forwarded when anonymous");
}
}