diff --git a/atcoder-problems-backend/src/crawler_utils.rs b/atcoder-problems-backend/src/crawler_utils.rs index 84c8cc4ca..8eea42497 100644 --- a/atcoder-problems-backend/src/crawler_utils.rs +++ b/atcoder-problems-backend/src/crawler_utils.rs @@ -244,29 +244,29 @@ async fn upsert_problems( new_problems: Vec, ) -> Result { for problem in &new_problems { - // Insert into problems table - let title = problem.title(); - let model = sql_entities::problems::ActiveModel { - id: Set(problem.id.clone()), - contest_id: Set(problem.contest_id.clone()), - problem_index: Set(problem.problem_index.clone()), - name: Set(problem.name.clone()), - title: Set(title), - }; - sql_entities::problems::Entity::insert(model) - .on_conflict( - OnConflict::column(sql_entities::problems::Column::Id) - .update_columns([ - sql_entities::problems::Column::ContestId, - sql_entities::problems::Column::ProblemIndex, - sql_entities::problems::Column::Name, - sql_entities::problems::Column::Title, - ]) - .to_owned(), - ) - .exec(db) + // Check if problem already exists; only insert if it doesn't. + // This prevents overwriting existing problem metadata (e.g. original contest_id) + // when the same problem_id appears in multiple contests (ADT reuses ABC problems). + // In other words: this is an ADT-specific countermeasure to keep the first-seen + // contest as the canonical one for the problem. + let existing = sql_entities::problems::Entity::find_by_id(problem.id.clone()) + .one(db) .await?; + if existing.is_none() { + let title = problem.title(); + let model = sql_entities::problems::ActiveModel { + id: Set(problem.id.clone()), + contest_id: Set(problem.contest_id.clone()), + problem_index: Set(problem.problem_index.clone()), + name: Set(problem.name.clone()), + title: Set(title), + }; + sql_entities::problems::Entity::insert(model) + .exec(db) + .await?; + } + // Insert into contest_problem table let contest_problem = sql_entities::contest_problem::ActiveModel { contest_id: Set(problem.contest_id.clone()), diff --git a/atcoder-problems-backend/tests/test_crawler_utils.rs b/atcoder-problems-backend/tests/test_crawler_utils.rs index 045f593be..eb6693d13 100644 --- a/atcoder-problems-backend/tests/test_crawler_utils.rs +++ b/atcoder-problems-backend/tests/test_crawler_utils.rs @@ -402,3 +402,93 @@ async fn test_crawl_contests_fetches_filtered_archive_categories() { .any(|contest| contest.id == "adt_all_20260612_2") ); } + +#[tokio::test] +async fn test_crawl_adt_problems() { + let db = setup_db().await.unwrap(); + + // 1. ABCのコンテスト、問題がクロールされる + sql_entities::contests::Entity::insert(sql_entities::contests::ActiveModel { + id: Set("abc001".to_string()), + start_epoch_second: Set(0), + duration_second: Set(0), + title: Set("AtCoder Beginner Contest 001".to_string()), + rate_change: Set("?".to_string()), + }) + .exec(&db) + .await + .unwrap(); + + let mut abc_fetcher = MockProblemFetcher::new(); + abc_fetcher + .expect_fetch_problems() + .withf(|contest_id| contest_id == "abc001") + .times(1) + .returning(|_| { + Ok(vec![Problem { + id: "abc001_a".to_string(), + contest_id: "abc001".to_string(), + problem_index: "A".to_string(), + name: "Problem A".to_string(), + }]) + }); + + atcoder_problems_backend::crawler_utils::crawl_problems(&abc_fetcher, &db) + .await + .unwrap(); + + // 2. 次に、ADTのコンテスト、問題がクロールされる + sql_entities::contests::Entity::insert(sql_entities::contests::ActiveModel { + id: Set("adt_all_20260612".to_string()), + start_epoch_second: Set(0), + duration_second: Set(0), + title: Set("AtCoder Daily Training 2026/06/12 All".to_string()), + rate_change: Set("-".to_string()), + }) + .exec(&db) + .await + .unwrap(); + + let mut adt_fetcher = MockProblemFetcher::new(); + adt_fetcher + .expect_fetch_problems() + .withf(|contest_id| contest_id == "adt_all_20260612") + .times(1) + .returning(|_| { + Ok(vec![Problem { + id: "abc001_a".to_string(), + contest_id: "adt_all_20260612".to_string(), + problem_index: "C".to_string(), + name: "Problem C".to_string(), + }]) + }); + + atcoder_problems_backend::crawler_utils::crawl_problems(&adt_fetcher, &db) + .await + .unwrap(); + + // 3. abc001_aがabc001とadt_all_20260612の両方に紐づいていることを確認する + let problems_after_adt = sql_entities::problems::Entity::find() + .all(&db) + .await + .unwrap(); + assert_eq!(problems_after_adt.len(), 1); + assert_eq!(problems_after_adt[0].id, "abc001_a"); + assert_eq!(problems_after_adt[0].contest_id, "abc001"); + + let contest_problems = sql_entities::contest_problem::Entity::find() + .all(&db) + .await + .unwrap(); + assert_eq!(contest_problems.len(), 2); + assert!(contest_problems.iter().any(|cp| cp.contest_id == "abc001" + && cp.problem_id == "abc001_a" + && cp.problem_index == "A")); + assert!( + contest_problems + .iter() + .any(|cp| cp.contest_id == "adt_all_20260612" + && cp.problem_id == "abc001_a" + && cp.problem_index == "C") + ); +}