fix(api): make season creation atomic
A duplicate episode number inserted the season and the earlier episodes before the unique constraint fired, so the retry that fixed the request collided with the half-written season instead. Duplicates are now a 422 before anything is written, and the inserts run in one transaction.
This commit is contained in:
@@ -583,6 +583,17 @@ pub async fn create_season(
|
||||
"episode number cannot be negative".into(),
|
||||
));
|
||||
}
|
||||
let mut numbers: Vec<i64> = input
|
||||
.episodes
|
||||
.iter()
|
||||
.map(|episode| episode.number)
|
||||
.collect();
|
||||
numbers.sort_unstable();
|
||||
if numbers.windows(2).any(|pair| pair[0] == pair[1]) {
|
||||
return Err(ApiError::Invalid(
|
||||
"episode numbers must be unique within the season".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// §4.1. The request does not say whether the episodes are wanted; the
|
||||
// series' auto_track rule does, through the one function that owns it.
|
||||
@@ -613,13 +624,16 @@ pub async fn create_season(
|
||||
apply_auto_track(&core_series(&series), &mut revealed);
|
||||
let [revealed] = revealed;
|
||||
|
||||
// One transaction: a rejected episode must not leave the season behind,
|
||||
// or the retry that fixes the request collides with it instead.
|
||||
let mut transaction = pool(&state)?.begin().await?;
|
||||
let season_id = sqlx::query!(
|
||||
"INSERT INTO seasons (series_id, number, tracked) VALUES (?, ?, ?)",
|
||||
series_id,
|
||||
input.number,
|
||||
revealed.season.tracked
|
||||
)
|
||||
.execute(pool(&state)?)
|
||||
.execute(&mut *transaction)
|
||||
.await?
|
||||
.last_insert_rowid();
|
||||
|
||||
@@ -632,9 +646,10 @@ pub async fn create_season(
|
||||
source.air_date,
|
||||
episode.wanted
|
||||
)
|
||||
.execute(pool(&state)?)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
}
|
||||
transaction.commit().await?;
|
||||
|
||||
let seasons = load_seasons(&state, series_id).await?;
|
||||
let season = seasons
|
||||
@@ -1070,6 +1085,40 @@ mod tests {
|
||||
assert_eq!(season["episodes"][0]["wanted"], false);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_rejected_season_leaves_nothing_behind_to_retry_over() {
|
||||
let (_dir, state, base) = application().await;
|
||||
let root_id = tv_root(&state, "main").await;
|
||||
let series = add_series(&base, root_id, false).await;
|
||||
let series_id = series["id"].as_i64().expect("id");
|
||||
|
||||
let response = reqwest::Client::new()
|
||||
.post(format!("{base}/api/series/{series_id}/seasons"))
|
||||
.json(&serde_json::json!({"number": 1, "episodes": [
|
||||
{"number": 1, "title": "One"},
|
||||
{"number": 1, "title": "One again"}
|
||||
]}))
|
||||
.send()
|
||||
.await
|
||||
.expect("create season");
|
||||
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||
|
||||
let seasons: i64 = sqlx::query_scalar("SELECT count(*) FROM seasons")
|
||||
.fetch_one(state.database().expect("database").pool())
|
||||
.await
|
||||
.expect("count seasons");
|
||||
assert_eq!(seasons, 0, "the season number stays free for the retry");
|
||||
|
||||
let season = add_season(
|
||||
&base,
|
||||
series_id,
|
||||
1,
|
||||
serde_json::json!([{"number": 1, "title": "One"}]),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(season["number"], 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn intent_is_set_on_seasons_and_on_single_episodes() {
|
||||
let (_dir, state, base) = application().await;
|
||||
|
||||
Reference in New Issue
Block a user