fix(api): make season creation atomic
ci / web (pull_request) Successful in 41s
ci / rust (pull_request) Successful in 1m47s
e2e / e2e (pull_request) Successful in 1m45s

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:
Miguel Palhas
2026-08-22 22:56:39 +01:00
parent 4cd0298dc1
commit 2e14e0b19c
+51 -2
View File
@@ -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;