Files
arr/crates/arr-api/src/roots.rs
T
Miguel Palhas 656ca6c259
ci / web (push) Successful in 1m5s
ci / rust (push) Successful in 1m56s
e2e / e2e (push) Failing after 7m5s
fix(api): skip mode-bit tests when they cannot bind
Four tests force a filesystem failure by freezing a directory to 0o555.
The CI container runs as root, mode bits do not constrain root, and the
rename those tests expect to fail succeeds — main has been red on
a_failed_rename_leaves_the_row_alone since the move-on-root-change work
landed, with nextest's fail-fast hiding the other three.

The guard probes the filesystem rather than the uid: what the tests
depend on is the refusal, and a container can hold CAP_DAC_OVERRIDE
without being uid 0.
2026-08-26 08:35:26 +01:00

1523 lines
58 KiB
Rust

//! Root folders (`DESIGN.md` §5.1): kind, audience, path and the one policy
//! attached. Reads serve the add flow (§9.2); writes are the settings view's.
use axum::extract::rejection::JsonRejection;
use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::Json;
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use crate::movies::{pool, ApiError, ErrorBody};
use crate::policies::parsed;
use crate::state::AppState;
/// A root folder joined with its policy, enough for the add flow to pre-fill
/// root and policy (DESIGN.md §9.2) without a second request.
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct Root {
pub id: i64,
pub kind: String,
pub audience: String,
pub path: String,
pub policy_id: i64,
pub policy_name: String,
}
/// The payload for creating or replacing a root.
#[derive(Debug, Clone, Deserialize, ToSchema)]
pub struct RootInput {
/// `movie` or `tv` — the Transmission label and layout prefix (§7.1, §7.4).
pub kind: String,
/// `main` or `kids`.
pub audience: String,
pub path: String,
pub policy_id: i64,
}
impl RootInput {
fn validate(&self) -> Result<(), String> {
if self.kind != "movie" && self.kind != "tv" {
return Err(format!("kind: '{}' is neither movie nor tv", self.kind));
}
if self.audience != "main" && self.audience != "kids" {
return Err(format!(
"audience: '{}' is neither main nor kids",
self.audience
));
}
if self.path.trim().is_empty() {
return Err("path: must not be empty".into());
}
Ok(())
}
async fn policy_exists(&self, state: &AppState) -> Result<(), ApiError> {
let exists: Option<i64> =
sqlx::query_scalar!("SELECT id FROM policies WHERE id = ?", self.policy_id)
.fetch_optional(pool(state)?)
.await?;
if exists.is_none() {
return Err(ApiError::Invalid(format!(
"policy_id: no policy {}",
self.policy_id
)));
}
Ok(())
}
}
/// Strips a trailing separator so `/mnt/media` and `/mnt/media/` name the
/// same root — otherwise a `PUT` that only adds or drops a trailing slash
/// takes the relocation branch and every planned destination conflicts with
/// its own source. Leaves a bare `/` alone.
fn normalize_path(path: &str) -> String {
let trimmed = path.trim();
let stripped = trimmed.trim_end_matches('/');
if stripped.is_empty() {
"/".to_owned()
} else {
stripped.to_owned()
}
}
async fn load_root(state: &AppState, id: i64) -> Result<Root, ApiError> {
sqlx::query_as!(
Root,
r#"SELECT r.id AS "id!: i64",
r.kind AS "kind!: String",
r.audience AS "audience!: String",
r.path AS "path!: String",
p.id AS "policy_id!: i64",
p.name AS "policy_name!: String"
FROM roots r JOIN policies p ON p.id = r.policy_id
WHERE r.id = ?"#,
id
)
.fetch_optional(pool(state)?)
.await?
.ok_or(ApiError::RootNotFound)
}
#[utoipa::path(
get, path = "/api/roots", tag = "roots",
responses(
(status = 200, body = [Root]),
(status = 500, body = ErrorBody),
(status = 503, body = ErrorBody)
)
)]
pub async fn list(State(state): State<AppState>) -> Result<Json<Vec<Root>>, ApiError> {
let roots = sqlx::query_as!(
Root,
r#"SELECT r.id AS "id!: i64", r.kind AS "kind!: String", r.audience AS "audience!: String", r.path AS "path!: String", p.id AS "policy_id!: i64", p.name AS "policy_name!: String" FROM roots r JOIN policies p ON p.id = r.policy_id ORDER BY r.id"#
)
.fetch_all(pool(&state)?)
.await?;
Ok(Json(roots))
}
#[utoipa::path(
get, path = "/api/roots/{root_id}", tag = "roots",
params(("root_id" = i64, Path, description = "Root row id")),
responses(
(status = 200, body = Root),
(status = 404, body = ErrorBody),
(status = 500, body = ErrorBody),
(status = 503, body = ErrorBody)
)
)]
pub async fn get(
State(state): State<AppState>,
Path(id): Path<i64>,
) -> Result<Json<Root>, ApiError> {
Ok(Json(load_root(&state, id).await?))
}
#[utoipa::path(
post, path = "/api/roots", tag = "roots", request_body = RootInput,
responses(
(status = 201, body = Root),
(status = 409, body = ErrorBody),
(status = 422, body = ErrorBody),
(status = 500, body = ErrorBody),
(status = 503, body = ErrorBody)
)
)]
pub async fn create(
State(state): State<AppState>,
body: Result<Json<RootInput>, JsonRejection>,
) -> Result<(StatusCode, Json<Root>), ApiError> {
let input = parsed(body)?;
input.validate().map_err(ApiError::Invalid)?;
input.policy_exists(&state).await?;
let path = normalize_path(&input.path);
path_is_free(&state, None, &path).await?;
let result = sqlx::query!(
"INSERT INTO roots (kind, audience, path, policy_id) VALUES (?, ?, ?, ?)",
input.kind,
input.audience,
path,
input.policy_id,
)
.execute(pool(&state)?)
.await
.map_err(root_conflict)?;
let root = load_root(&state, result.last_insert_rowid()).await?;
Ok((StatusCode::CREATED, Json(root)))
}
#[utoipa::path(
put, path = "/api/roots/{root_id}", tag = "roots", request_body = RootInput,
params(("root_id" = i64, Path, description = "Root row id")),
responses(
(status = 200, body = Root),
(status = 404, body = ErrorBody),
(status = 409, body = ErrorBody),
(status = 422, body = ErrorBody),
(status = 500, body = ErrorBody),
(status = 503, body = ErrorBody)
)
)]
pub async fn update(
State(state): State<AppState>,
Path(id): Path<i64>,
body: Result<Json<RootInput>, JsonRejection>,
) -> Result<Json<Root>, ApiError> {
let input = parsed(body)?;
input.validate().map_err(ApiError::Invalid)?;
input.policy_exists(&state).await?;
let current = load_root(&state, id).await?;
let path = normalize_path(&input.path);
// #244: rows written before #243 can hold a trailing separator, so the
// stored value is normalised too. Comparing a normalised payload against
// a raw stored value means the row can never compare equal: every edit,
// policy changes included, takes the relocation branch, and there each
// planned destination is its own source. The normalised value is what
// `relocate_root` gets as well, so no planned path carries a doubled
// separator.
let current_path = normalize_path(&current.path);
// A path change moves every §7.4 title folder under this root with the
// row (issue #236), the same way changing a title's root moves one
// (#228). Disk first, row second: a failed rename leaves the root row
// alone, so the operator sees the library where its files actually are
// and can retry. A path already taken is refused before any of it, since
// the write would fail afterwards anyway.
let relocation = if path == current_path {
None
} else {
path_is_free(&state, Some(id), &path).await?;
Some(crate::relocate::relocate_root(&state, id, &current_path, &path).await?)
};
let mut transaction = pool(&state)?.begin().await?;
let written: Result<(), sqlx::Error> = async {
sqlx::query!(
r#"UPDATE roots SET kind = ?, audience = ?, path = ?, policy_id = ?,
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
WHERE id = ?"#,
input.kind,
input.audience,
path,
input.policy_id,
id,
)
.execute(&mut *transaction)
.await?;
if let Some(relocation) = &relocation {
relocation.rewrite_rows(&mut transaction).await?;
}
Ok(())
}
.await;
let committed = match written {
Ok(()) => transaction.commit().await.map_err(root_conflict),
Err(error) => Err(root_conflict(error)),
};
if let Err(error) = committed {
if let Some(relocation) = &relocation {
relocation.undo().await;
}
return Err(error);
}
if relocation
.as_ref()
.is_some_and(crate::relocate::Relocation::moved_files)
{
crate::relocate::refresh_jellyfin(&state).await;
}
// Verdicts already stored under this root were reached under the old
// policy; §9.3's deck and the grab gate both read them (`reclassify`).
if input.policy_id != current.policy_id {
crate::reclassify::root(&state, id).await?;
}
Ok(Json(load_root(&state, id).await?))
}
/// The unique index on `path` would catch this after the move; catching it
/// first keeps a doomed write from touching the disk at all. `except` is the
/// row being updated, or `None` when creating.
///
/// The stored side is normalised in SQL, mirroring [`normalize_path`], so
/// `/mnt/media/x` and `/mnt/media/x/` cannot be two roots for one directory
/// (#244). The unique index cannot see that — it compares the raw strings —
/// and migration 0031 leaves any pair that already collides alone rather
/// than failing to apply, so such a row can still be on disk.
async fn path_is_free(state: &AppState, except: Option<i64>, path: &str) -> Result<(), ApiError> {
let taken: Option<i64> = sqlx::query_scalar!(
r#"SELECT id AS "id!: i64" FROM roots
WHERE CASE WHEN rtrim(path, '/') = '' THEN '/' ELSE rtrim(path, '/') END = ?
AND (? IS NULL OR id <> ?)"#,
path,
except,
except
)
.fetch_optional(pool(state)?)
.await?;
if taken.is_some() {
return Err(ApiError::Conflict(
"a root with this path already exists".into(),
));
}
Ok(())
}
/// A duplicate path or a duplicate (kind, audience) pair is a settings
/// mistake the operator can fix, not a server fault.
fn root_conflict(error: sqlx::Error) -> ApiError {
if error
.as_database_error()
.is_some_and(sqlx::error::DatabaseError::is_unique_violation)
{
return ApiError::Conflict(
"a root with this path or this kind and audience already exists".into(),
);
}
ApiError::from(error)
}
#[utoipa::path(
delete, path = "/api/roots/{root_id}", tag = "roots",
params(("root_id" = i64, Path, description = "Root row id")),
responses(
(status = 204),
(status = 404, body = ErrorBody),
(status = 409, body = ErrorBody),
(status = 500, body = ErrorBody),
(status = 503, body = ErrorBody)
)
)]
pub async fn delete(
State(state): State<AppState>,
Path(id): Path<i64>,
) -> Result<StatusCode, ApiError> {
let movies: i64 = sqlx::query_scalar!(
r#"SELECT count(*) AS "count!: i64" FROM movies WHERE root_id = ?"#,
id
)
.fetch_one(pool(&state)?)
.await?;
if movies > 0 {
return Err(ApiError::Conflict(format!(
"this root still owns {movies} movie{} — move them first",
if movies == 1 { "" } else { "s" }
)));
}
let series: i64 = sqlx::query_scalar!(
r#"SELECT count(*) AS "count!: i64" FROM series WHERE root_id = ?"#,
id
)
.fetch_one(pool(&state)?)
.await?;
if series > 0 {
return Err(ApiError::Conflict(format!(
"this root still owns {series} series — move them first"
)));
}
let result = sqlx::query!("DELETE FROM roots WHERE id = ?", id)
.execute(pool(&state)?)
.await?;
if result.rows_affected() == 0 {
return Err(ApiError::RootNotFound);
}
Ok(StatusCode::NO_CONTENT)
}
#[cfg(test)]
mod tests {
use crate::{router, AppState, Upstreams};
use axum::http::StatusCode;
async fn application() -> (tempfile::TempDir, AppState, String) {
let dir = tempfile::tempdir().expect("tempdir");
let database = arr_db::Db::connect(dir.path().join("arr.db"))
.await
.expect("database");
database.migrate().await.expect("migrate");
let state = AppState::new(Upstreams::new(
"http://127.0.0.1:1".into(),
"http://127.0.0.1:1".into(),
))
.expect("state")
.with_database(database);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind");
let address = listener.local_addr().expect("address");
let app = router(state.clone());
tokio::spawn(async move { axum::serve(listener, app).await.expect("serve") });
(dir, state, format!("http://{address}"))
}
#[tokio::test]
async fn roots_carry_their_policy_name() {
let (_dir, _state, base) = application().await;
let roots: Vec<serde_json::Value> = reqwest::get(format!("{base}/api/roots"))
.await
.expect("roots")
.json()
.await
.expect("json");
assert_eq!(roots.len(), 4, "the seeded movie and TV roots: {roots:?}");
assert_eq!(roots[0]["audience"], "main");
assert_eq!(roots[0]["policy_name"], "Movies — main");
assert_eq!(roots[1]["audience"], "kids");
assert!(roots[1]["path"]
.as_str()
.is_some_and(|p| p.contains("kids")));
assert_eq!(roots[2]["kind"], "tv");
assert_eq!(roots[2]["policy_name"], "TV — main");
assert_eq!(roots[3]["kind"], "tv");
assert_eq!(roots[3]["policy_name"], "TV — kids");
}
fn root_input(policy_id: i64) -> serde_json::Value {
serde_json::json!({
"kind": "movie",
"audience": "archive",
"path": "/mnt/media/movies/archive",
"policy_id": policy_id,
})
}
async fn first_policy_ids(base: &str) -> Vec<i64> {
let policies: Vec<serde_json::Value> = reqwest::get(format!("{base}/api/policies"))
.await
.expect("policies")
.json()
.await
.expect("policies json");
policies
.into_iter()
.map(|policy| policy["id"].as_i64().expect("policy id"))
.collect()
}
async fn policy_id_named(base: &str, name: &str) -> i64 {
let policies: Vec<serde_json::Value> = reqwest::get(format!("{base}/api/policies"))
.await
.expect("policies")
.json()
.await
.expect("policies json");
policies
.iter()
.find(|policy| policy["name"] == name)
.and_then(|policy| policy["id"].as_i64())
.expect("policy id")
}
async fn stamped_eligible_release(state: &AppState, name: &str, size: i64) -> i64 {
let parsed = arr_parse::parse(name);
sqlx::query_scalar::<_, i64>(
"INSERT INTO releases (indexer_id, guid, name, size, seeders, download_url, parsed, score, verdict)
VALUES (7, ?, ?, ?, 40, 'url', ?, 0, 'eligible') RETURNING id",
)
.bind(name)
.bind(name)
.bind(size)
.bind(serde_json::to_string(&parsed).expect("parsed json"))
.fetch_one(state.database().expect("database").pool())
.await
.expect("release")
}
/// A movie under the movie main root, its release stamped eligible.
async fn movie_with_eligible_release(state: &AppState, base: &str) -> i64 {
let movie: serde_json::Value = reqwest::Client::new()
.post(format!("{base}/api/movies"))
.json(&serde_json::json!({
"tmdb_id": 693_134, "title": "Dune Part Two", "year": 2024,
"original_language": "en", "root_id": 1, "overrides": {}
}))
.send()
.await
.expect("create movie")
.json()
.await
.expect("movie json");
let movie_id = movie["id"].as_i64().expect("movie id");
let release_id = stamped_eligible_release(
state,
"Dune Part Two 2024 1080p WEB-DL ENGLISH x264-GROUP",
6_i64 * (1 << 30),
)
.await;
sqlx::query("INSERT INTO movie_releases (movie_id, release_id) VALUES (?, ?)")
.bind(movie_id)
.bind(release_id)
.execute(state.database().expect("database").pool())
.await
.expect("movie association");
movie_id
}
/// A series under the given TV root, with a season 9 pack stamped
/// eligible. Ten episodes in 15 GiB: 1.5 GiB each, inside the 1080p band.
async fn series_with_eligible_pack(state: &AppState, base: &str, root_id: i64) -> i64 {
let client = reqwest::Client::new();
let series: serde_json::Value = client
.post(format!("{base}/api/series"))
.json(&serde_json::json!({
"tmdb_id": 82_728, "title": "Bluey", "year": 2018,
"original_language": "en", "root_id": root_id,
"auto_track": false
}))
.send()
.await
.expect("create series")
.json()
.await
.expect("series json");
let series_id = series["id"].as_i64().expect("series id");
let episodes: Vec<serde_json::Value> = (1..=10)
.map(|number| {
serde_json::json!({
"number": number, "title": format!("Episode {number}"),
"air_date": "2025-01-01"
})
})
.collect();
let season: serde_json::Value = client
.post(format!("{base}/api/series/{series_id}/seasons"))
.json(&serde_json::json!({"number": 9, "episodes": episodes}))
.send()
.await
.expect("create season")
.json()
.await
.expect("season json");
let season_id = season["id"].as_i64().expect("season id");
let release_id = stamped_eligible_release(
state,
"Bluey S09 1080p WEB-DL ENGLISH x264-GROUP",
15_i64 * (1 << 30),
)
.await;
sqlx::query("INSERT INTO season_releases (season_id, release_id) VALUES (?, ?)")
.bind(season_id)
.bind(release_id)
.execute(state.database().expect("database").pool())
.await
.expect("season association");
series_id
}
async fn verdict_of(url: String) -> serde_json::Value {
let releases: Vec<serde_json::Value> = reqwest::get(url)
.await
.expect("releases")
.json()
.await
.expect("releases json");
releases[0]["verdict"].clone()
}
/// #241: `PUT /api/roots/{id}` writing a different `policy_id`
/// re-derives the stored verdicts of every title under the root (§5.1)
/// — and of nothing outside it.
#[tokio::test]
async fn repointing_a_root_at_another_policy_rederives_its_library() {
let (_dir, state, base) = application().await;
let pool = state.database().expect("database").pool();
let client = reqwest::Client::new();
let movie_id = movie_with_eligible_release(&state, &base).await;
let tv_main: i64 =
sqlx::query_scalar("SELECT id FROM roots WHERE kind = 'tv' AND audience = 'main'")
.fetch_one(pool)
.await
.expect("tv root");
let series_id = series_with_eligible_pack(&state, &base, tv_main).await;
// Point the movie main root at the kids policy; the path stays, so
// nothing on disk is touched.
let kids_policy = policy_id_named(&base, "Movies — kids").await;
let response = client
.put(format!("{base}/api/roots/1"))
.json(&serde_json::json!({
"kind": "movie", "audience": "main",
"path": "/mnt/media/movies/main", "policy_id": kids_policy
}))
.send()
.await
.expect("repoint movie root");
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
verdict_of(format!("{base}/api/movies/{movie_id}/releases")).await,
"waived",
"kids requires pt-PT, English is a soft fail"
);
// The TV root was not repointed, so its library is untouched.
assert_eq!(
verdict_of(format!("{base}/api/series/{series_id}/seasons/9/releases")).await,
"eligible"
);
// Now the TV root; the series' pack re-derives too.
let tv_kids_policy = policy_id_named(&base, "TV — kids").await;
let response = client
.put(format!("{base}/api/roots/{tv_main}"))
.json(&serde_json::json!({
"kind": "tv", "audience": "main",
"path": "/mnt/media/tv/main", "policy_id": tv_kids_policy
}))
.send()
.await
.expect("repoint tv root");
assert_eq!(
response.status(),
StatusCode::OK,
"{:?}",
response.text().await
);
assert_eq!(
verdict_of(format!("{base}/api/series/{series_id}/seasons/9/releases")).await,
"waived",
"kids requires pt-PT, English is a soft fail"
);
}
#[tokio::test]
async fn a_root_round_trips_through_create_and_update() {
let (_dir, _state, base) = application().await;
let policy_ids = first_policy_ids(&base).await;
let roots: Vec<serde_json::Value> = reqwest::get(format!("{base}/api/roots"))
.await
.expect("roots")
.json()
.await
.expect("roots json");
// the schema only knows movie and tv, main and kids
let rejected = reqwest::Client::new()
.post(format!("{base}/api/roots"))
.json(&root_input(policy_ids[0]))
.send()
.await
.expect("create root");
assert_eq!(rejected.status(), StatusCode::UNPROCESSABLE_ENTITY);
// (movie, kids) is taken by a seeded root, so free the pair first
let kids_root = roots[1]["id"].as_i64().expect("movie kids root id");
let freed = reqwest::Client::new()
.delete(format!("{base}/api/roots/{kids_root}"))
.send()
.await
.expect("free the seeded movie kids root");
assert_eq!(freed.status(), StatusCode::NO_CONTENT);
let mut payload = root_input(policy_ids[0]);
payload["audience"] = serde_json::json!("kids");
payload["path"] = serde_json::json!("/mnt/media/movies/archive");
let created: serde_json::Value = reqwest::Client::new()
.post(format!("{base}/api/roots"))
.json(&payload)
.send()
.await
.expect("create root")
.json()
.await
.expect("created json");
assert_eq!(created["audience"], "kids");
assert_eq!(created["policy_name"], "Movies — main");
let id = created["id"].as_i64().expect("id");
payload["path"] = serde_json::json!("/mnt/media/movies/archive-4k");
payload["policy_id"] = policy_ids[1].into();
let updated: serde_json::Value = reqwest::Client::new()
.put(format!("{base}/api/roots/{id}"))
.json(&payload)
.send()
.await
.expect("update root")
.json()
.await
.expect("updated json");
assert_eq!(updated["path"], "/mnt/media/movies/archive-4k");
assert_eq!(updated["policy_name"], "Movies — kids");
let deleted = reqwest::Client::new()
.delete(format!("{base}/api/roots/{id}"))
.send()
.await
.expect("delete root");
assert_eq!(deleted.status(), StatusCode::NO_CONTENT);
assert_eq!(
reqwest::get(format!("{base}/api/roots/{id}"))
.await
.expect("get deleted")
.status(),
StatusCode::NOT_FOUND
);
}
#[tokio::test]
async fn an_unknown_kind_or_policy_is_a_422_naming_the_field() {
let (_dir, _state, base) = application().await;
let policy_ids = first_policy_ids(&base).await;
for (mut payload, field) in [
(root_input(999), "policy_id"),
(root_input(policy_ids[0]), "kind"),
] {
payload["audience"] = serde_json::json!("main");
if field == "kind" {
payload["kind"] = serde_json::json!("book");
payload["path"] = serde_json::json!("/mnt/media/movies/elsewhere");
}
let response = reqwest::Client::new()
.post(format!("{base}/api/roots"))
.json(&payload)
.send()
.await
.expect("create root");
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
let body: serde_json::Value = response.json().await.expect("error body");
assert!(
body["error"].as_str().expect("text").starts_with(field),
"{field}: {body}"
);
}
}
#[tokio::test]
async fn a_duplicate_path_or_pair_conflicts() {
let (_dir, _state, base) = application().await;
let mut path = root_input(1);
path["audience"] = serde_json::json!("kids");
path["path"] = serde_json::json!("/mnt/media/movies/kids");
let response = reqwest::Client::new()
.post(format!("{base}/api/roots"))
.json(&path)
.send()
.await
.expect("duplicate path create");
assert_eq!(response.status(), StatusCode::CONFLICT);
let mut pair = root_input(1);
pair["audience"] = serde_json::json!("main");
pair["path"] = serde_json::json!("/mnt/media/movies/somewhere-new");
let response = reqwest::Client::new()
.post(format!("{base}/api/roots"))
.json(&pair)
.send()
.await
.expect("duplicate pair create");
assert_eq!(response.status(), StatusCode::CONFLICT);
}
#[tokio::test]
async fn a_root_with_titles_refuses_to_die() {
let (_dir, _state, base) = application().await;
let roots: Vec<serde_json::Value> = reqwest::get(format!("{base}/api/roots"))
.await
.expect("roots")
.json()
.await
.expect("roots json");
let main_root = roots[0]["id"].as_i64().expect("root id");
let created = reqwest::Client::new()
.post(format!("{base}/api/movies"))
.json(&serde_json::json!({
"tmdb_id": 693_134, "title": "Dune Part Two", "year": 2024,
"original_language": "en", "root_id": main_root,
}))
.send()
.await
.expect("create movie");
assert_eq!(created.status(), StatusCode::CREATED);
let response = reqwest::Client::new()
.delete(format!("{base}/api/roots/{main_root}"))
.send()
.await
.expect("delete occupied root");
assert_eq!(response.status(), StatusCode::CONFLICT);
let body: serde_json::Value = response.json().await.expect("error body");
assert!(
body["error"].as_str().expect("text").contains("movie"),
"{body}"
);
// an empty root deletes fine
let empty_root = roots[3]["id"].as_i64().expect("tv kids root id");
let response = reqwest::Client::new()
.delete(format!("{base}/api/roots/{empty_root}"))
.send()
.await
.expect("delete empty root");
assert_eq!(response.status(), StatusCode::NO_CONTENT);
}
/// A path written straight into the row, no normalisation — how a root
/// created before #243 could end up holding a trailing separator.
async fn point_root_at_raw(state: &AppState, root_id: i64, path: &str) {
sqlx::query("UPDATE roots SET path = ? WHERE id = ?")
.bind(path)
.bind(root_id)
.execute(state.database().expect("database").pool())
.await
.expect("store the raw path");
}
async fn point_root_at(state: &AppState, root_id: i64, path: &std::path::Path) {
sqlx::query("UPDATE roots SET path = ? WHERE id = ?")
.bind(path.to_str().expect("utf-8 root"))
.bind(root_id)
.execute(state.database().expect("database").pool())
.await
.expect("point the root at the tempdir");
}
async fn add_movie(base: &str, tmdb_id: i64, title: &str, root_id: i64) -> i64 {
let created: serde_json::Value = reqwest::Client::new()
.post(format!("{base}/api/movies"))
.json(&serde_json::json!({
"tmdb_id": tmdb_id, "title": title, "year": 2024,
"original_language": "en", "root_id": root_id,
}))
.send()
.await
.expect("create movie")
.json()
.await
.expect("movie json");
created["id"].as_i64().expect("movie id")
}
/// One movie's §7.4 folder on disk with a feature in it, and the
/// `media_files` row that points at the feature.
async fn library_folder(
state: &AppState,
movie_id: i64,
root: &std::path::Path,
folder: &str,
) -> std::path::PathBuf {
let folder = root.join(folder);
tokio::fs::create_dir_all(&folder)
.await
.expect("create title folder");
let feature = folder.join("feature.mkv");
tokio::fs::write(&feature, b"feature").await.expect("write");
sqlx::query(
"INSERT INTO media_files (owner_kind, owner_id, path, size) VALUES ('movie', ?, ?, 7)",
)
.bind(movie_id)
.bind(feature.to_str().expect("utf-8 path"))
.execute(state.database().expect("database").pool())
.await
.expect("media file");
folder
}
async fn root_payload(base: &str, id: i64, path: &str) -> serde_json::Value {
let root: serde_json::Value = reqwest::get(format!("{base}/api/roots/{id}"))
.await
.expect("root")
.json()
.await
.expect("root json");
serde_json::json!({
"kind": root["kind"],
"audience": root["audience"],
"path": path,
"policy_id": root["policy_id"],
})
}
async fn file_paths(state: &AppState) -> Vec<String> {
sqlx::query_scalar("SELECT path FROM media_files ORDER BY id")
.fetch_all(state.database().expect("database").pool())
.await
.expect("media files")
}
async fn stored_path(base: &str, id: i64) -> String {
let root: serde_json::Value = reqwest::get(format!("{base}/api/roots/{id}"))
.await
.expect("root")
.json()
.await
.expect("root json");
root["path"].as_str().expect("path").to_owned()
}
/// Issue #236: changing a root's path renames every §7.4 title folder
/// under it into the new path and rewrites the `media_files` rows in the
/// same write. Titles under other roots are not this root's business.
#[tokio::test]
async fn changing_a_root_path_moves_every_title_under_it() {
let (_dir, state, base) = application().await;
let old = tempfile::tempdir().expect("old root");
let other = tempfile::tempdir().expect("other root");
point_root_at(&state, 1, old.path()).await;
point_root_at(&state, 2, other.path()).await;
let mut folders = Vec::new();
for (tmdb_id, title) in [(100, "Dune"), (101, "Arrival"), (102, "Sicario")] {
let id = add_movie(&base, tmdb_id, title, 1).await;
folders.push(library_folder(&state, id, old.path(), title).await);
}
let elsewhere = add_movie(&base, 200, "Prisoners", 2).await;
let untouched = library_folder(&state, elsewhere, other.path(), "Prisoners").await;
// The new path need not exist yet — pointing a root somewhere fresh
// is the ordinary case.
let new = old.path().parent().expect("parent").join("relocated-main");
let response = reqwest::Client::new()
.put(format!("{base}/api/roots/1"))
.json(&root_payload(&base, 1, new.to_str().expect("utf-8")).await)
.send()
.await
.expect("move the root");
assert_eq!(response.status(), StatusCode::OK);
for folder in &folders {
assert!(!folder.exists(), "{} left the old path", folder.display());
}
for title in ["Dune", "Arrival", "Sicario"] {
assert!(
new.join(title).join("feature.mkv").exists(),
"{title} arrived under the new path"
);
}
assert!(
untouched.join("feature.mkv").exists(),
"a title under another root is untouched"
);
let paths = file_paths(&state).await;
for path in paths.iter().take(3) {
assert!(
std::path::Path::new(path).starts_with(&new),
"the row follows the file: {path}"
);
assert!(
std::path::Path::new(path).exists(),
"the rewritten path describes the disk: {path}"
);
}
assert!(
std::path::Path::new(&paths[3]).starts_with(other.path()),
"the other root's row is untouched: {}",
paths[3]
);
assert_eq!(stored_path(&base, 1).await, new.to_str().expect("utf-8"));
tokio::fs::remove_dir_all(&new).await.expect("clean up");
}
/// Issue #236: a root with nothing under it changes path with no
/// filesystem work at all — the new path is not even created.
#[tokio::test]
async fn an_empty_root_changes_path_with_no_filesystem_work() {
let (_dir, state, base) = application().await;
let home = tempfile::tempdir().expect("home");
let new = home.path().join("nothing-here");
let response = reqwest::Client::new()
.put(format!("{base}/api/roots/1"))
.json(&root_payload(&base, 1, new.to_str().expect("utf-8")).await)
.send()
.await
.expect("move the root");
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(stored_path(&base, 1).await, new.to_str().expect("utf-8"));
assert!(!new.exists(), "nothing was created on disk");
assert!(file_paths(&state).await.is_empty());
}
/// Issue #236: a destination already holding a folder of that name is a
/// conflict, not an overwrite, and it is caught before anything moves.
#[tokio::test]
async fn a_squatted_destination_refuses_the_whole_move() {
let (_dir, state, base) = application().await;
let old = tempfile::tempdir().expect("old root");
let new = tempfile::tempdir().expect("new root");
point_root_at(&state, 1, old.path()).await;
let first = add_movie(&base, 100, "Dune", 1).await;
let second = add_movie(&base, 101, "Arrival", 1).await;
let dune = library_folder(&state, first, old.path(), "Dune").await;
let arrival = library_folder(&state, second, old.path(), "Arrival").await;
tokio::fs::create_dir_all(new.path().join("Arrival"))
.await
.expect("squatter");
let before = file_paths(&state).await;
let response = reqwest::Client::new()
.put(format!("{base}/api/roots/1"))
.json(&root_payload(&base, 1, new.path().to_str().expect("utf-8")).await)
.send()
.await
.expect("move the root");
assert_eq!(response.status(), StatusCode::CONFLICT);
assert!(dune.exists(), "not even the first folder moved");
assert!(arrival.exists());
assert_eq!(file_paths(&state).await, before, "the rows are untouched");
assert_eq!(
stored_path(&base, 1).await,
old.path().to_str().expect("utf-8"),
"the root still points where the files are"
);
}
/// Integration review of the Feedback pass 2 blitz: the retry the module
/// documents has to actually converge. An undo is best-effort, so a move
/// can fail and leave one folder at the destination with its row still
/// naming the source. Every later attempt used to 409 against the
/// operator's own half-moved library, and the only way out was moving the
/// folder back by hand.
#[tokio::test]
async fn a_folder_left_at_the_destination_by_a_failed_undo_does_not_block_the_retry() {
let (_dir, state, base) = application().await;
let old = tempfile::tempdir().expect("old root");
let new = tempfile::tempdir().expect("new root");
point_root_at(&state, 1, old.path()).await;
let first = add_movie(&base, 100, "Dune", 1).await;
let second = add_movie(&base, 101, "Arrival", 1).await;
let dune = library_folder(&state, first, old.path(), "Dune").await;
let arrival = library_folder(&state, second, old.path(), "Arrival").await;
// What a failed undo leaves behind: Arrival is already at the new
// path, its row still points at the old one, and the root row was
// never changed.
tokio::fs::create_dir_all(new.path())
.await
.expect("the new root");
tokio::fs::rename(&arrival, new.path().join("Arrival"))
.await
.expect("strand the folder");
assert!(!arrival.exists());
let response = reqwest::Client::new()
.put(format!("{base}/api/roots/1"))
.json(&root_payload(&base, 1, new.path().to_str().expect("utf-8")).await)
.send()
.await
.expect("retry the move");
assert_eq!(
response.status(),
StatusCode::OK,
"the stranded folder is the state we wanted, not a conflict"
);
assert!(!dune.exists(), "the folder still on disk moved");
assert!(new.path().join("Dune").exists());
assert!(new.path().join("Arrival").exists(), "left where it was");
let paths = file_paths(&state).await;
let root = new.path().to_str().expect("utf-8");
assert!(
paths.iter().all(|path| path.starts_with(root)),
"every row follows the root, the stranded one included: {paths:?}"
);
assert_eq!(stored_path(&base, 1).await, root);
}
/// Issue #236: the move is all or nothing. A folder that cannot be
/// renamed part-way through takes the whole change down: what had already
/// moved is moved back, the root row keeps the old path, and the rows
/// still describe the disk, so the operator can fix the folder and send
/// the same request again.
#[cfg(unix)]
#[tokio::test]
async fn one_folder_that_cannot_move_puts_the_others_back() {
use std::os::unix::fs::PermissionsExt;
if !crate::privilege::mode_bits_bind().await {
crate::privilege::skipped_because_privileged(
"one_folder_that_cannot_move_puts_the_others_back",
);
return;
}
let (_dir, state, base) = application().await;
let old = tempfile::tempdir().expect("old root");
let home = tempfile::tempdir().expect("home of the new path");
let new = home.path().join("relocated-main");
point_root_at(&state, 1, old.path()).await;
let mut folders = Vec::new();
for (tmdb_id, title) in [(100, "Dune"), (101, "Arrival"), (102, "Sicario")] {
let id = add_movie(&base, tmdb_id, title, 1).await;
folders.push(library_folder(&state, id, old.path(), title).await);
}
// Moving a directory to another parent rewrites its `..`, which needs
// write permission on the directory itself: the second title cannot
// move, the first already has.
let stuck = folders[1].clone();
tokio::fs::set_permissions(&stuck, std::fs::Permissions::from_mode(0o555))
.await
.expect("freeze the second title folder");
let before = file_paths(&state).await;
let response = reqwest::Client::new()
.put(format!("{base}/api/roots/1"))
.json(&root_payload(&base, 1, new.to_str().expect("utf-8")).await)
.send()
.await
.expect("move the root");
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
let body: serde_json::Value = response.json().await.expect("error body");
assert!(
body["error"]
.as_str()
.expect("text")
.contains(stuck.to_str().expect("utf-8")),
"the error names the folder that stopped the move: {body}"
);
tokio::fs::set_permissions(&stuck, std::fs::Permissions::from_mode(0o755))
.await
.expect("thaw the second title folder");
for folder in &folders {
assert!(
folder.join("feature.mkv").exists(),
"{} is back where the row says it is",
folder.display()
);
}
assert!(
!new.exists(),
"the directory created for the failed move was removed, since it never held anything"
);
assert_eq!(file_paths(&state).await, before, "the rows are untouched");
assert_eq!(
stored_path(&base, 1).await,
old.path().to_str().expect("utf-8"),
"the root still points where the files are"
);
// The same request again, with the folder fixed, is the retry path.
let response = reqwest::Client::new()
.put(format!("{base}/api/roots/1"))
.json(&root_payload(&base, 1, new.to_str().expect("utf-8")).await)
.send()
.await
.expect("retry the move");
assert_eq!(response.status(), StatusCode::OK);
for title in ["Dune", "Arrival", "Sicario"] {
assert!(new.join(title).join("feature.mkv").exists());
}
}
/// Issue #243: a directory a failed move did not create is not this
/// request's to remove, even though it sits at the same new root path.
#[cfg(unix)]
#[tokio::test]
async fn a_new_root_that_already_existed_survives_a_failed_move() {
use std::os::unix::fs::PermissionsExt;
if !crate::privilege::mode_bits_bind().await {
crate::privilege::skipped_because_privileged(
"a_new_root_that_already_existed_survives_a_failed_move",
);
return;
}
let (_dir, state, base) = application().await;
let old = tempfile::tempdir().expect("old root");
let new = tempfile::tempdir().expect("new root, already there");
point_root_at(&state, 1, old.path()).await;
let id = add_movie(&base, 100, "Dune", 1).await;
let stuck = library_folder(&state, id, old.path(), "Dune").await;
tokio::fs::set_permissions(&stuck, std::fs::Permissions::from_mode(0o555))
.await
.expect("freeze the title folder");
let response = reqwest::Client::new()
.put(format!("{base}/api/roots/1"))
.json(&root_payload(&base, 1, new.path().to_str().expect("utf-8")).await)
.send()
.await
.expect("move the root");
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
tokio::fs::set_permissions(&stuck, std::fs::Permissions::from_mode(0o755))
.await
.expect("thaw the title folder");
assert!(
new.path().exists(),
"a directory this request did not create is left alone"
);
}
/// Issue #243: a path differing only by a trailing separator is the same
/// root, not a self-conflict — `PUT` with it is a no-op.
#[tokio::test]
async fn a_trailing_separator_is_not_a_path_change() {
let (_dir, state, base) = application().await;
let old = tempfile::tempdir().expect("old root");
point_root_at(&state, 1, old.path()).await;
let id = add_movie(&base, 100, "Dune", 1).await;
let folder = library_folder(&state, id, old.path(), "Dune").await;
let mut trailing = old.path().to_str().expect("utf-8").to_owned();
trailing.push('/');
let response = reqwest::Client::new()
.put(format!("{base}/api/roots/1"))
.json(&root_payload(&base, 1, &trailing).await)
.send()
.await
.expect("update root with a trailing separator");
assert_eq!(response.status(), StatusCode::OK);
assert!(folder.exists(), "nothing on disk moved");
assert_eq!(
stored_path(&base, 1).await,
old.path().to_str().expect("utf-8"),
"the stored path keeps no trailing separator"
);
}
/// Issue #243: `create` normalises the same way, so a trailing separator
/// cannot dodge the duplicate-path check.
#[tokio::test]
async fn create_normalises_a_trailing_separator_before_checking_for_conflicts() {
let (_dir, _state, base) = application().await;
let policy_ids = first_policy_ids(&base).await;
let roots: Vec<serde_json::Value> = reqwest::get(format!("{base}/api/roots"))
.await
.expect("roots")
.json()
.await
.expect("roots json");
let existing_path = roots[0]["path"].as_str().expect("path").to_owned();
// Free the (movie, kids) pair so the conflict below is the path
// check alone, not the (kind, audience) uniqueness check.
let kids_root = roots[1]["id"].as_i64().expect("movie kids root id");
let freed = reqwest::Client::new()
.delete(format!("{base}/api/roots/{kids_root}"))
.send()
.await
.expect("free the seeded movie kids root");
assert_eq!(freed.status(), StatusCode::NO_CONTENT);
let mut payload = root_input(policy_ids[0]);
payload["audience"] = serde_json::json!("kids");
payload["path"] = serde_json::json!(format!("{existing_path}/"));
let response = reqwest::Client::new()
.post(format!("{base}/api/roots"))
.json(&payload)
.send()
.await
.expect("create root with a trailing separator");
assert_eq!(
response.status(),
StatusCode::CONFLICT,
"a trailing separator does not dodge the duplicate-path check"
);
}
/// Issue #236: a TV root carries series folders, whose files hang off
/// episodes rather than off the title row. They move with the root too.
#[tokio::test]
async fn a_tv_root_moves_its_series_folders() {
let (_dir, state, base) = application().await;
let old = tempfile::tempdir().expect("old root");
let home = tempfile::tempdir().expect("home of the new path");
let new = home.path().join("relocated-tv");
point_root_at(&state, 3, old.path()).await;
let pool = state.database().expect("database").pool();
sqlx::query(
"INSERT INTO series (id, tmdb_id, title, year, original_language, root_id)
VALUES (1, 82728, 'Bluey', 2018, 'en', 3)",
)
.execute(pool)
.await
.expect("series");
sqlx::query("INSERT INTO seasons (id, series_id, number) VALUES (1, 1, 1)")
.execute(pool)
.await
.expect("season");
sqlx::query(
"INSERT INTO episodes (id, season_id, number, title) VALUES (1, 1, 2, 'Hospital')",
)
.execute(pool)
.await
.expect("episode");
let season = old
.path()
.join("Bluey (2018) [tmdbid-82728]")
.join("Season 01");
tokio::fs::create_dir_all(&season)
.await
.expect("create season folder");
let episode = season.join("Bluey - S01E02.mkv");
tokio::fs::write(&episode, b"episode").await.expect("write");
sqlx::query(
"INSERT INTO media_files (owner_kind, owner_id, path, size) VALUES ('episode', 1, ?, 9)",
)
.bind(episode.to_str().expect("utf-8 path"))
.execute(pool)
.await
.expect("media file");
let response = reqwest::Client::new()
.put(format!("{base}/api/roots/3"))
.json(&root_payload(&base, 3, new.to_str().expect("utf-8")).await)
.send()
.await
.expect("move the root");
assert_eq!(response.status(), StatusCode::OK);
assert!(
new.join("Bluey (2018) [tmdbid-82728]")
.join("Season 01")
.join("Bluey - S01E02.mkv")
.exists(),
"the whole series folder moved, season layout intact"
);
let paths = file_paths(&state).await;
assert!(
std::path::Path::new(&paths[0]).starts_with(&new)
&& std::path::Path::new(&paths[0]).exists(),
"the episode row follows the file: {}",
paths[0]
);
}
/// Issue #244: a root *stored* with a trailing separator — creatable
/// through the API at any point before #243 — could not be edited at
/// all. The payload was normalised and the stored value was not, so no
/// payload compared equal: every edit took the relocation branch, where
/// every planned destination is its own source and the pre-check 409s.
#[tokio::test]
async fn a_root_stored_with_a_trailing_separator_can_still_be_edited() {
let (_dir, state, base) = application().await;
let old = tempfile::tempdir().expect("old root");
let unslashed = old.path().to_str().expect("utf-8").to_owned();
point_root_at_raw(&state, 1, &format!("{unslashed}/")).await;
let id = add_movie(&base, 100, "Dune", 1).await;
let folder = library_folder(&state, id, old.path(), "Dune").await;
// What the settings view sends back: the path exactly as stored,
// separator included, with only the policy changed.
let kids_policy = policy_id_named(&base, "Movies — kids").await;
let mut payload = root_payload(&base, 1, &format!("{unslashed}/")).await;
payload["policy_id"] = kids_policy.into();
let response = reqwest::Client::new()
.put(format!("{base}/api/roots/1"))
.json(&payload)
.send()
.await
.expect("edit the root");
assert_eq!(
response.status(),
StatusCode::OK,
"{:?}",
response.text().await
);
assert!(folder.join("feature.mkv").exists(), "nothing on disk moved");
assert_eq!(
stored_path(&base, 1).await,
unslashed,
"the row is left normalised, so the next edit compares equal too"
);
// And the same payload without the separator is not a relocation
// either.
let response = reqwest::Client::new()
.put(format!("{base}/api/roots/1"))
.json(&root_payload(&base, 1, &unslashed).await)
.send()
.await
.expect("edit the root again");
assert_eq!(response.status(), StatusCode::OK);
assert!(folder.join("feature.mkv").exists(), "still nothing moved");
}
/// Issue #244: a real path change from a root stored with a trailing
/// separator plans from the normalised value, so neither a destination
/// nor a rewritten row carries a doubled separator.
#[tokio::test]
async fn relocating_a_slash_stored_root_plans_no_doubled_separator() {
let (_dir, state, base) = application().await;
let old = tempfile::tempdir().expect("old root");
let home = tempfile::tempdir().expect("home of the new path");
let new = home.path().join("relocated-main");
let unslashed = old.path().to_str().expect("utf-8").to_owned();
point_root_at_raw(&state, 1, &format!("{unslashed}/")).await;
let id = add_movie(&base, 100, "Dune", 1).await;
let folder = library_folder(&state, id, old.path(), "Dune").await;
let response = reqwest::Client::new()
.put(format!("{base}/api/roots/1"))
.json(&root_payload(&base, 1, new.to_str().expect("utf-8")).await)
.send()
.await
.expect("move the root");
assert_eq!(
response.status(),
StatusCode::OK,
"{:?}",
response.text().await
);
assert!(!folder.exists(), "the folder left the old path");
assert!(new.join("Dune").join("feature.mkv").exists());
let paths = file_paths(&state).await;
assert!(
!paths[0].contains("//"),
"no doubled separator in the rewritten row: {}",
paths[0]
);
assert!(
std::path::Path::new(&paths[0]).exists(),
"the rewritten path describes the disk: {}",
paths[0]
);
assert_eq!(stored_path(&base, 1).await, new.to_str().expect("utf-8"));
}
/// Issue #244: `create_dir_all` can materialise more than one level for
/// a root path pointed somewhere fresh. A failed move removes every
/// level it created, not only the leaf — and still nothing that was
/// already on disk.
#[cfg(unix)]
#[tokio::test]
async fn a_failed_move_removes_every_level_it_created() {
use std::os::unix::fs::PermissionsExt;
if !crate::privilege::mode_bits_bind().await {
crate::privilege::skipped_because_privileged(
"a_failed_move_removes_every_level_it_created",
);
return;
}
let (_dir, state, base) = application().await;
let old = tempfile::tempdir().expect("old root");
let home = tempfile::tempdir().expect("home of the new path");
// Three levels below a directory that is already there.
let top = home.path().join("media-v2");
let new = top.join("tv").join("kids");
point_root_at(&state, 1, old.path()).await;
let id = add_movie(&base, 100, "Dune", 1).await;
let stuck = library_folder(&state, id, old.path(), "Dune").await;
// Moving a directory to another parent rewrites its `..`, which
// needs write permission on the directory itself.
tokio::fs::set_permissions(&stuck, std::fs::Permissions::from_mode(0o555))
.await
.expect("freeze the title folder");
let response = reqwest::Client::new()
.put(format!("{base}/api/roots/1"))
.json(&root_payload(&base, 1, new.to_str().expect("utf-8")).await)
.send()
.await
.expect("move the root");
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
tokio::fs::set_permissions(&stuck, std::fs::Permissions::from_mode(0o755))
.await
.expect("thaw the title folder");
assert!(
!top.exists(),
"every level the failed move created is gone, not only the leaf"
);
assert!(
home.path().exists(),
"the level that was already there is left alone"
);
assert!(
stuck.join("feature.mkv").exists(),
"the folder is still where the row says it is"
);
assert_eq!(
stored_path(&base, 1).await,
old.path().to_str().expect("utf-8")
);
}
/// Issue #244: two roots naming one directory, differing only by a
/// trailing separator, are not two roots. The unique index compares raw
/// strings and cannot see it, so the check normalises both sides.
#[tokio::test]
async fn a_slash_stored_path_is_not_free_for_another_root() {
let (_dir, state, base) = application().await;
point_root_at_raw(&state, 2, "/mnt/media/movies/archive/").await;
// An update onto the stripped form of a path another root holds.
let response = reqwest::Client::new()
.put(format!("{base}/api/roots/1"))
.json(&root_payload(&base, 1, "/mnt/media/movies/archive").await)
.send()
.await
.expect("update onto the other root's path");
assert_eq!(response.status(), StatusCode::CONFLICT);
// And a create, which the unique index would have let through.
let policy_ids = first_policy_ids(&base).await;
let mut payload = root_input(policy_ids[0]);
payload["kind"] = serde_json::json!("tv");
payload["audience"] = serde_json::json!("main");
payload["path"] = serde_json::json!("/mnt/media/movies/archive");
let deleted = reqwest::Client::new()
.delete(format!("{base}/api/roots/3"))
.send()
.await
.expect("free the (tv, main) pair");
assert_eq!(deleted.status(), StatusCode::NO_CONTENT);
let response = reqwest::Client::new()
.post(format!("{base}/api/roots"))
.json(&payload)
.send()
.await
.expect("create onto the other root's path");
assert_eq!(response.status(), StatusCode::CONFLICT);
}
/// Issue #236: a path another root already holds is refused before the
/// disk is touched at all.
#[tokio::test]
async fn a_path_another_root_holds_is_refused_before_any_move() {
let (_dir, state, base) = application().await;
let old = tempfile::tempdir().expect("old root");
let taken = tempfile::tempdir().expect("taken root");
point_root_at(&state, 1, old.path()).await;
point_root_at(&state, 2, taken.path()).await;
let id = add_movie(&base, 100, "Dune", 1).await;
let folder = library_folder(&state, id, old.path(), "Dune").await;
let response = reqwest::Client::new()
.put(format!("{base}/api/roots/1"))
.json(&root_payload(&base, 1, taken.path().to_str().expect("utf-8")).await)
.send()
.await
.expect("move the root");
assert_eq!(response.status(), StatusCode::CONFLICT);
assert!(folder.exists(), "nothing moved");
assert!(!taken.path().join("Dune").exists());
}
}