363 lines
12 KiB
Rust
363 lines
12 KiB
Rust
//! Owner tags: a many-to-many label on titles that drives filtered views and
|
|
//! notification routing (`DESIGN.md` §4.3). Movies only for now — series
|
|
//! arrives with TV (§13 phase 6); the join table already carries `title_kind`
|
|
//! so nothing here changes shape when that lands.
|
|
|
|
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::state::AppState;
|
|
|
|
#[derive(Debug, Clone, Serialize, ToSchema)]
|
|
pub struct Owner {
|
|
pub id: i64,
|
|
pub name: String,
|
|
pub ntfy_topic: String,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, ToSchema)]
|
|
pub struct CreateOwner {
|
|
pub name: String,
|
|
pub ntfy_topic: String,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, ToSchema)]
|
|
pub struct UpdateOwner {
|
|
pub name: Option<String>,
|
|
pub ntfy_topic: Option<String>,
|
|
}
|
|
|
|
async fn load_owner(state: &AppState, id: i64) -> Result<Owner, ApiError> {
|
|
sqlx::query_as!(
|
|
Owner,
|
|
r#"SELECT id AS "id!: i64", name AS "name!: String", ntfy_topic AS "ntfy_topic!: String" FROM owners WHERE id = ?"#,
|
|
id
|
|
)
|
|
.fetch_optional(pool(state)?)
|
|
.await?
|
|
.ok_or(ApiError::OwnerNotFound)
|
|
}
|
|
|
|
#[utoipa::path(
|
|
get, path = "/api/owners", tag = "owners",
|
|
responses(
|
|
(status = 200, body = [Owner]),
|
|
(status = 500, body = ErrorBody),
|
|
(status = 503, body = ErrorBody)
|
|
)
|
|
)]
|
|
pub async fn list(State(state): State<AppState>) -> Result<Json<Vec<Owner>>, ApiError> {
|
|
let owners = sqlx::query_as!(
|
|
Owner,
|
|
r#"SELECT id AS "id!: i64", name AS "name!: String", ntfy_topic AS "ntfy_topic!: String" FROM owners ORDER BY name"#
|
|
)
|
|
.fetch_all(pool(&state)?)
|
|
.await?;
|
|
Ok(Json(owners))
|
|
}
|
|
|
|
#[utoipa::path(
|
|
post, path = "/api/owners", tag = "owners", request_body = CreateOwner,
|
|
responses(
|
|
(status = 201, body = Owner),
|
|
(status = 409, body = ErrorBody),
|
|
(status = 422, body = ErrorBody),
|
|
(status = 500, body = ErrorBody),
|
|
(status = 503, body = ErrorBody)
|
|
)
|
|
)]
|
|
pub async fn create(
|
|
State(state): State<AppState>,
|
|
Json(input): Json<CreateOwner>,
|
|
) -> Result<(StatusCode, Json<Owner>), ApiError> {
|
|
let name = input.name.trim();
|
|
let ntfy_topic = input.ntfy_topic.trim();
|
|
if name.is_empty() || ntfy_topic.is_empty() {
|
|
return Err(ApiError::Invalid("name and ntfy_topic are required".into()));
|
|
}
|
|
let result = sqlx::query!(
|
|
"INSERT INTO owners (name, ntfy_topic) VALUES (?, ?)",
|
|
name,
|
|
ntfy_topic
|
|
)
|
|
.execute(pool(&state)?)
|
|
.await?;
|
|
Ok((
|
|
StatusCode::CREATED,
|
|
Json(load_owner(&state, result.last_insert_rowid()).await?),
|
|
))
|
|
}
|
|
|
|
#[utoipa::path(
|
|
get, path = "/api/owners/{owner_id}", tag = "owners",
|
|
params(("owner_id" = i64, Path, description = "Owner row id")),
|
|
responses(
|
|
(status = 200, body = Owner),
|
|
(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<Owner>, ApiError> {
|
|
Ok(Json(load_owner(&state, id).await?))
|
|
}
|
|
|
|
#[utoipa::path(
|
|
patch, path = "/api/owners/{owner_id}", tag = "owners", request_body = UpdateOwner,
|
|
params(("owner_id" = i64, Path, description = "Owner row id")),
|
|
responses(
|
|
(status = 200, body = Owner),
|
|
(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>,
|
|
Json(input): Json<UpdateOwner>,
|
|
) -> Result<Json<Owner>, ApiError> {
|
|
let current = load_owner(&state, id).await?;
|
|
let name = input.name.unwrap_or(current.name);
|
|
let ntfy_topic = input.ntfy_topic.unwrap_or(current.ntfy_topic);
|
|
let name = name.trim();
|
|
let ntfy_topic = ntfy_topic.trim();
|
|
if name.is_empty() || ntfy_topic.is_empty() {
|
|
return Err(ApiError::Invalid("name and ntfy_topic are required".into()));
|
|
}
|
|
sqlx::query!(
|
|
"UPDATE owners SET name = ?, ntfy_topic = ? WHERE id = ?",
|
|
name,
|
|
ntfy_topic,
|
|
id
|
|
)
|
|
.execute(pool(&state)?)
|
|
.await?;
|
|
Ok(Json(load_owner(&state, id).await?))
|
|
}
|
|
|
|
#[utoipa::path(
|
|
delete, path = "/api/owners/{owner_id}", tag = "owners",
|
|
params(("owner_id" = i64, Path, description = "Owner row id")),
|
|
responses(
|
|
(status = 204),
|
|
(status = 404, 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 result = sqlx::query!("DELETE FROM owners WHERE id = ?", id)
|
|
.execute(pool(&state)?)
|
|
.await?;
|
|
if result.rows_affected() == 0 {
|
|
return Err(ApiError::OwnerNotFound);
|
|
}
|
|
Ok(StatusCode::NO_CONTENT)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::{router, Upstreams};
|
|
|
|
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("connect database");
|
|
database.migrate().await.expect("migrate database");
|
|
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}"))
|
|
}
|
|
|
|
async fn create_owner(base: &str, name: &str) -> serde_json::Value {
|
|
let response = reqwest::Client::new()
|
|
.post(format!("{base}/api/owners"))
|
|
.json(&serde_json::json!({ "name": name, "ntfy_topic": format!("topic-{name}") }))
|
|
.send()
|
|
.await
|
|
.expect("create owner");
|
|
assert_eq!(response.status(), StatusCode::CREATED);
|
|
response.json().await.expect("owner json")
|
|
}
|
|
|
|
async fn create_movie(base: &str, tmdb_id: i64, root_id: i64) -> serde_json::Value {
|
|
let response = reqwest::Client::new()
|
|
.post(format!("{base}/api/movies"))
|
|
.json(&serde_json::json!({
|
|
"tmdb_id": tmdb_id, "title": "Dune Part Two", "year": 2024,
|
|
"original_language": "en", "root_id": root_id,
|
|
}))
|
|
.send()
|
|
.await
|
|
.expect("create movie");
|
|
assert_eq!(response.status(), StatusCode::CREATED);
|
|
response.json().await.expect("movie json")
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn crud_round_trips_an_owner() {
|
|
let (_dir, _state, base) = application().await;
|
|
let owner = create_owner(&base, "miguel").await;
|
|
let id = owner["id"].as_i64().expect("id");
|
|
|
|
let updated: serde_json::Value = reqwest::Client::new()
|
|
.patch(format!("{base}/api/owners/{id}"))
|
|
.json(&serde_json::json!({ "ntfy_topic": "new-topic" }))
|
|
.send()
|
|
.await
|
|
.expect("update")
|
|
.json()
|
|
.await
|
|
.expect("updated json");
|
|
assert_eq!(updated["name"], "miguel");
|
|
assert_eq!(updated["ntfy_topic"], "new-topic");
|
|
|
|
let deleted = reqwest::Client::new()
|
|
.delete(format!("{base}/api/owners/{id}"))
|
|
.send()
|
|
.await
|
|
.expect("delete");
|
|
assert_eq!(deleted.status(), StatusCode::NO_CONTENT);
|
|
assert_eq!(
|
|
reqwest::get(format!("{base}/api/owners/{id}"))
|
|
.await
|
|
.expect("get deleted")
|
|
.status(),
|
|
StatusCode::NOT_FOUND
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn a_movie_with_two_owners_appears_in_both_filtered_views_but_once_on_disk() {
|
|
let (_dir, state, base) = application().await;
|
|
let pool = state.database().expect("database").pool();
|
|
let root_id: i64 = sqlx::query_scalar("SELECT id FROM roots WHERE audience = 'main'")
|
|
.fetch_one(pool)
|
|
.await
|
|
.expect("main root");
|
|
|
|
let movie = create_movie(&base, 693_134, root_id).await;
|
|
let movie_id = movie["id"].as_i64().expect("id");
|
|
let alice = create_owner(&base, "alice").await;
|
|
let bob = create_owner(&base, "bob").await;
|
|
let alice_id = alice["id"].as_i64().expect("id");
|
|
let bob_id = bob["id"].as_i64().expect("id");
|
|
|
|
for owner_id in [alice_id, bob_id] {
|
|
let response = reqwest::Client::new()
|
|
.put(format!("{base}/api/movies/{movie_id}/owners/{owner_id}"))
|
|
.send()
|
|
.await
|
|
.expect("tag");
|
|
assert_eq!(response.status(), StatusCode::NO_CONTENT);
|
|
}
|
|
|
|
for owner_id in [alice_id, bob_id] {
|
|
let filtered: Vec<serde_json::Value> =
|
|
reqwest::get(format!("{base}/api/movies?owner_id={owner_id}"))
|
|
.await
|
|
.expect("filtered list")
|
|
.json()
|
|
.await
|
|
.expect("filtered json");
|
|
assert_eq!(filtered.len(), 1, "owner {owner_id}");
|
|
assert_eq!(filtered[0]["id"], movie_id);
|
|
}
|
|
|
|
let on_disk: i64 = sqlx::query_scalar("SELECT count(*) FROM movies WHERE id = ?")
|
|
.bind(movie_id)
|
|
.fetch_one(pool)
|
|
.await
|
|
.expect("count");
|
|
assert_eq!(on_disk, 1, "the title exists once regardless of tags");
|
|
|
|
let tags: Vec<serde_json::Value> =
|
|
reqwest::get(format!("{base}/api/movies/{movie_id}/owners"))
|
|
.await
|
|
.expect("owners of movie")
|
|
.json()
|
|
.await
|
|
.expect("owners json");
|
|
assert_eq!(tags.len(), 2);
|
|
|
|
let response = reqwest::Client::new()
|
|
.delete(format!("{base}/api/movies/{movie_id}/owners/{alice_id}"))
|
|
.send()
|
|
.await
|
|
.expect("untag");
|
|
assert_eq!(response.status(), StatusCode::NO_CONTENT);
|
|
|
|
let alice_view: Vec<serde_json::Value> =
|
|
reqwest::get(format!("{base}/api/movies?owner_id={alice_id}"))
|
|
.await
|
|
.expect("alice view")
|
|
.json()
|
|
.await
|
|
.expect("alice json");
|
|
assert!(alice_view.is_empty());
|
|
|
|
let bob_view: Vec<serde_json::Value> =
|
|
reqwest::get(format!("{base}/api/movies?owner_id={bob_id}"))
|
|
.await
|
|
.expect("bob view")
|
|
.json()
|
|
.await
|
|
.expect("bob json");
|
|
assert_eq!(bob_view.len(), 1);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn tagging_a_missing_movie_or_owner_is_not_found() {
|
|
let (_dir, state, base) = application().await;
|
|
let owner = create_owner(&base, "alice").await;
|
|
let owner_id = owner["id"].as_i64().expect("id");
|
|
|
|
let response = reqwest::Client::new()
|
|
.put(format!("{base}/api/movies/999/owners/{owner_id}"))
|
|
.send()
|
|
.await
|
|
.expect("tag missing movie");
|
|
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
|
|
|
let pool = state.database().expect("database").pool();
|
|
let root_id: i64 = sqlx::query_scalar("SELECT id FROM roots WHERE audience = 'main'")
|
|
.fetch_one(pool)
|
|
.await
|
|
.expect("main root");
|
|
let movie = create_movie(&base, 693_134, root_id).await;
|
|
let movie_id = movie["id"].as_i64().expect("id");
|
|
|
|
let response = reqwest::Client::new()
|
|
.put(format!("{base}/api/movies/{movie_id}/owners/999"))
|
|
.send()
|
|
.await
|
|
.expect("tag missing owner");
|
|
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
|
}
|
|
}
|