From 7aac534bd452e10f54cc6b3afa00ba8b40856672 Mon Sep 17 00:00:00 2001 From: iamodh Date: Thu, 26 Feb 2026 12:02:32 +0900 Subject: [PATCH 1/9] feat(m7): add Race#registration_closed? for deadline judgment Co-Authored-By: Claude Opus 4.6 --- PLAN.md | 2 +- app/models/race.rb | 4 ++++ test/models/race_test.rb | 8 ++++++++ 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/PLAN.md b/PLAN.md index 8a1ed06..328028f 100755 --- a/PLAN.md +++ b/PLAN.md @@ -211,7 +211,7 @@ bundle exec rails runner "puts 'OK'" **Unit Tests** -- [ ] Race#registration_closed? - 마감일 경과 시 true +- [x] Race#registration_closed? - 마감일 경과 시 true - [ ] Course#available? - 마감 OR 정원 초과 시 false **Integration Tests (P0)** diff --git a/app/models/race.rb b/app/models/race.rb index bc8352a..a5b7705 100644 --- a/app/models/race.rb +++ b/app/models/race.rb @@ -3,4 +3,8 @@ class Race < ApplicationRecord has_many :registrations, dependent: :destroy scope :upcoming, -> { where("registration_deadline > ?", Time.current).order(:event_date) } + + def registration_closed? + Time.current > registration_deadline + end end diff --git a/test/models/race_test.rb b/test/models/race_test.rb index ee02592..a031544 100644 --- a/test/models/race_test.rb +++ b/test/models/race_test.rb @@ -11,4 +11,12 @@ class RaceTest < ActiveSupport::TestCase race = races(:marathon_2026) assert_includes race.registrations, registrations(:hong_5km) end + + test "registration_closed? returns true when deadline has passed" do + assert races(:closed_race).registration_closed? + end + + test "registration_closed? returns false when deadline is in the future" do + assert_not races(:marathon_2026).registration_closed? + end end From 4eb119bf15274a9dddff2d67e20ef5a4131a93f4 Mon Sep 17 00:00:00 2001 From: iamodh Date: Thu, 26 Feb 2026 12:14:20 +0900 Subject: [PATCH 2/9] refactor(m7): use registration_closed? in Course#available? Co-Authored-By: Claude Opus 4.6 --- PLAN.md | 2 +- app/models/course.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/PLAN.md b/PLAN.md index 328028f..5b2fc70 100755 --- a/PLAN.md +++ b/PLAN.md @@ -212,7 +212,7 @@ bundle exec rails runner "puts 'OK'" **Unit Tests** - [x] Race#registration_closed? - 마감일 경과 시 true -- [ ] Course#available? - 마감 OR 정원 초과 시 false +- [x] Course#available? - 마감 OR 정원 초과 시 false **Integration Tests (P0)** diff --git a/app/models/course.rb b/app/models/course.rb index 8d6462d..86e5804 100644 --- a/app/models/course.rb +++ b/app/models/course.rb @@ -13,7 +13,7 @@ def full? end def available? - !full? && race.registration_deadline > Time.current + !race.registration_closed? && !full? end def create_registration!(params) From e2c95f22221484ba56294854396102294e18753a Mon Sep 17 00:00:00 2001 From: iamodh Date: Thu, 26 Feb 2026 12:30:47 +0900 Subject: [PATCH 3/9] feat(m7): block registration after deadline with error message Co-Authored-By: Claude Opus 4.6 --- PLAN.md | 2 +- app/controllers/registrations_controller.rb | 7 +++++++ test/integration/registration_form_test.rb | 19 +++++++++++++++++++ 3 files changed, 27 insertions(+), 1 deletion(-) diff --git a/PLAN.md b/PLAN.md index 5b2fc70..c00a6c3 100755 --- a/PLAN.md +++ b/PLAN.md @@ -216,7 +216,7 @@ bundle exec rails runner "puts 'OK'" **Integration Tests (P0)** -- [ ] 마감일 경과 후 신청 → 차단, 메시지: "신청 기간이 종료되었습니다." +- [x] 마감일 경과 후 신청 → 차단, 메시지: "신청 기간이 종료되었습니다." - [ ] 정원 마감 후 신청 → 차단, 메시지: "선택하신 코스의 정원이 마감되었습니다." **완료 조건:** 마감 후 신청 완전 차단 diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb index 255e0d3..fece5bd 100644 --- a/app/controllers/registrations_controller.rb +++ b/app/controllers/registrations_controller.rb @@ -6,6 +6,13 @@ def new def create @course = Course.find(params[:course_id]) + + unless @course.available? + alert = @course.race.registration_closed? ? "신청 기간이 종료되었습니다." : "선택하신 코스의 정원이 마감되었습니다." + redirect_to new_course_registration_path(@course), alert: alert + return + end + @registration = @course.create_registration!(registration_params) redirect_to root_path rescue Course::CapacityExceededError => e diff --git a/test/integration/registration_form_test.rb b/test/integration/registration_form_test.rb index d43ae2d..f5a3c6b 100644 --- a/test/integration/registration_form_test.rb +++ b/test/integration/registration_form_test.rb @@ -77,6 +77,25 @@ class RegistrationFormTest < ActionDispatch::IntegrationTest assert_select ".field-errors", text: /이미 동일한 이름과 전화번호로 신청된 내역이 있습니다/ end + test "registration fails when registration deadline has passed" do + course = courses(:closed_five_km) + + assert_no_difference "Registration.count" do + post course_registrations_path(course), params: { + registration: { + name: "테스트", + phone_number: "01099999999", + birth_date: "1990-01-01", + gender: "male", + address: "서울시 강남구" + } + } + end + + assert_redirected_to new_course_registration_path(course) + assert_equal "신청 기간이 종료되었습니다.", flash[:alert] + end + test "submitting with missing fields re-renders form with validation errors and preserves input" do course = courses(:five_km) kept_name = "김철수" From 2cf2c45b28867c9ce87f4473c1b4ad28ae3d1080 Mon Sep 17 00:00:00 2001 From: iamodh Date: Thu, 26 Feb 2026 12:31:55 +0900 Subject: [PATCH 4/9] docs(m7): mark capacity block integration test as complete Existing test already covers this scenario via available? check. Co-Authored-By: Claude Opus 4.6 --- PLAN.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/PLAN.md b/PLAN.md index c00a6c3..9918e0b 100755 --- a/PLAN.md +++ b/PLAN.md @@ -217,7 +217,7 @@ bundle exec rails runner "puts 'OK'" **Integration Tests (P0)** - [x] 마감일 경과 후 신청 → 차단, 메시지: "신청 기간이 종료되었습니다." -- [ ] 정원 마감 후 신청 → 차단, 메시지: "선택하신 코스의 정원이 마감되었습니다." +- [x] 정원 마감 후 신청 → 차단, 메시지: "선택하신 코스의 정원이 마감되었습니다." **완료 조건:** 마감 후 신청 완전 차단 From cd9f6a36116e87458dcc92b4cd1af529d9716400 Mon Sep 17 00:00:00 2001 From: iamodh Date: Thu, 26 Feb 2026 12:36:16 +0900 Subject: [PATCH 5/9] docs(m7): add commit hashes to PLAN.md Co-Authored-By: Claude Opus 4.6 --- PLAN.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/PLAN.md b/PLAN.md index 9918e0b..e38b4ae 100755 --- a/PLAN.md +++ b/PLAN.md @@ -221,7 +221,7 @@ bundle exec rails runner "puts 'OK'" **완료 조건:** 마감 후 신청 완전 차단 -- Commits: +- Commits: 7aac534, 4eb119b, e2c95f2, 2cf2c45 --- From 71abcf8f75fc95b6c9ec5d03d59f0fabbc13013f Mon Sep 17 00:00:00 2001 From: iamodh Date: Thu, 26 Feb 2026 12:45:10 +0900 Subject: [PATCH 6/9] feat(m7): add RegistrationClosedError to create_registration! MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move deadline check into model for data integrity consistency with capacity check. Controller available? remains as UX fast-reject. Update TECHSPEC §6.3 and §7.3 accordingly. Co-Authored-By: Claude Opus 4.6 --- TECHSPEC.md | 16 +++++++--------- app/controllers/registrations_controller.rb | 2 +- app/models/course.rb | 8 ++++---- test/models/course_test.rb | 14 ++++++++++++++ 4 files changed, 26 insertions(+), 14 deletions(-) diff --git a/TECHSPEC.md b/TECHSPEC.md index 5b3265e..983237d 100755 --- a/TECHSPEC.md +++ b/TECHSPEC.md @@ -572,7 +572,7 @@ end def create create_registration(@course, registration_params) redirect_to complete_path, notice: "신청이 완료되었습니다." -rescue CapacityExceededError => e +rescue RegistrationClosedError, CapacityExceededError => e redirect_to new_registration_path, alert: e.message rescue ActiveRecord::RecordNotUnique redirect_to new_registration_path, alert: "이미 동일한 이름과 전화번호로 신청된 내역이 있습니다." @@ -584,12 +584,10 @@ def create_registration(course, params) Course.transaction do course.lock! - current_count = course.registrations.where(status: :applied).count - if current_count >= course.capacity - raise CapacityExceededError, "선택하신 코스의 정원이 마감되었습니다." - end + raise RegistrationClosedError, "신청 기간이 종료되었습니다." if course.race.registration_closed? + raise CapacityExceededError, "선택하신 코스의 정원이 마감되었습니다." if course.full? - course.registrations.create!(params.merge(status: :applied)) + course.registrations.create!(params.merge(race: course.race, status: :applied)) end end ``` @@ -598,7 +596,7 @@ end 1. 트랜잭션 시작 2. Course 행 잠금 (`lock!` → FOR UPDATE) -3. applied 상태 신청 수 확인 +3. 마감일 경과? → RegistrationClosedError, 롤백, 끝 4. 정원 초과? → CapacityExceededError, 롤백, 끝 5. 정원 OK → Registration INSERT 시도 6. DB unique index 검사 (INSERT 시점에 자동) @@ -655,9 +653,9 @@ end **흐름:** -1. 마감 여부 먼저 체크 (트랜잭션 불필요) +1. 컨트롤러: `available?`로 빠른 거부 (UX용, 트랜잭션 불필요) 2. 마감 시 사유별 메시지 반환 -3. 통과 시 6.2 정원 + 중복 체크 (트랜잭션) 진행 +3. 통과 시 `create_registration!` → 트랜잭션 내 마감 + 정원 + 중복 재검증 (데이터 무결성 보장) --- diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb index fece5bd..9021f35 100644 --- a/app/controllers/registrations_controller.rb +++ b/app/controllers/registrations_controller.rb @@ -15,7 +15,7 @@ def create @registration = @course.create_registration!(registration_params) redirect_to root_path - rescue Course::CapacityExceededError => e + rescue Course::RegistrationClosedError, Course::CapacityExceededError => e redirect_to new_course_registration_path(@course), alert: e.message rescue ActiveRecord::RecordNotUnique redirect_to new_course_registration_path(@course), diff --git a/app/models/course.rb b/app/models/course.rb index 86e5804..82abe87 100644 --- a/app/models/course.rb +++ b/app/models/course.rb @@ -1,5 +1,6 @@ class Course < ApplicationRecord class CapacityExceededError < StandardError; end + class RegistrationClosedError < StandardError; end belongs_to :race has_many :registrations, dependent: :destroy @@ -20,11 +21,10 @@ def create_registration!(params) Course.transaction do lock! - if full? - raise CapacityExceededError, "선택하신 코스의 정원이 마감되었습니다." - end + raise RegistrationClosedError, "신청 기간이 종료되었습니다." if race.registration_closed? + raise CapacityExceededError, "선택하신 코스의 정원이 마감되었습니다." if full? - registrations.create!(params) + registrations.create!(params.merge(race: race)) end end end diff --git a/test/models/course_test.rb b/test/models/course_test.rb index dfb9d36..a5023f5 100644 --- a/test/models/course_test.rb +++ b/test/models/course_test.rb @@ -40,4 +40,18 @@ class CourseTest < ActiveSupport::TestCase course = courses(:closed_five_km) assert_not course.available? end + + test "create_registration! raises RegistrationClosedError when deadline has passed" do + course = courses(:closed_five_km) + + assert_raises(Course::RegistrationClosedError) do + course.create_registration!( + name: "테스트", + phone_number: "01099999999", + birth_date: "1990-01-01", + gender: "male", + address: "서울시 강남구" + ) + end + end end From 2ed4b3afb79efc2b122dd2815e0716ba74b1c103 Mon Sep 17 00:00:00 2001 From: iamodh Date: Thu, 26 Feb 2026 12:45:51 +0900 Subject: [PATCH 7/9] docs(m7): update commit hashes in PLAN.md Co-Authored-By: Claude Opus 4.6 --- PLAN.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/PLAN.md b/PLAN.md index e38b4ae..c2cb34c 100755 --- a/PLAN.md +++ b/PLAN.md @@ -221,7 +221,7 @@ bundle exec rails runner "puts 'OK'" **완료 조건:** 마감 후 신청 완전 차단 -- Commits: 7aac534, 4eb119b, e2c95f2, 2cf2c45 +- Commits: 7aac534, 4eb119b, e2c95f2, 2cf2c45, 71abcf8 --- From d649c7b743af565d0780c1079c953c4deda2a15c Mon Sep 17 00:00:00 2001 From: iamodh Date: Thu, 26 Feb 2026 12:47:23 +0900 Subject: [PATCH 8/9] docs: add deadline check to data integrity strategy document Co-Authored-By: Claude Opus 4.6 --- docs/resume.md | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/docs/resume.md b/docs/resume.md index 70d5a85..17accad 100644 --- a/docs/resume.md +++ b/docs/resume.md @@ -22,14 +22,16 @@ ```ruby Course.transaction do - lock! # SELECT ... FOR UPDATE + lock! # SELECT ... FOR UPDATE + raise RegistrationClosedError if race.registration_closed? raise CapacityExceededError if full? registrations.create!(params) end ``` - `lock!`으로 같은 코스에 대한 동시 트랜잭션을 직렬화 -- 두 번째 요청은 첫 번째 커밋 이후에 count를 다시 확인하므로 정원 초과를 정확히 감지 +- 마감일 체크와 정원 체크를 동일한 트랜잭션 내에서 수행 — 모델이 자기 데이터를 스스로 보호 +- 컨트롤러의 `available?` 사전 체크는 UX를 위한 빠른 거부용이고, 모델이 최종 안전장치 ### 중복 신청 방지: 3단계 방어 @@ -102,7 +104,9 @@ rescue ActiveRecord::RecordNotUnique | 계층 | 방어 대상 | 수단 | 실패 시 | |------|-----------|------|---------| -| Application | 순차 중복 요청 | `validates uniqueness` | 폼 re-render + 에러 메시지 | -| Application | 정원 초과 | `lock!` + count 체크 | redirect + flash alert | +| Controller | 빠른 거부 (UX) | `available?` 사전 체크 | redirect + flash alert | +| Model | 마감일 초과 | `lock!` + `registration_closed?` | `RegistrationClosedError` | +| Model | 정원 초과 | `lock!` + count 체크 | `CapacityExceededError` | +| Model | 순차 중복 요청 | `validates uniqueness` | 폼 re-render + 에러 메시지 | | Database | 동시 중복 요청 | Unique Index | `RecordNotUnique` 발생 | | Controller | 500 에러 방지 | `rescue RecordNotUnique` | redirect + flash alert | From fb43941dbf524c649d7e217fa2e2946da9769a93 Mon Sep 17 00:00:00 2001 From: iamodh Date: Thu, 26 Feb 2026 12:53:06 +0900 Subject: [PATCH 9/9] docs: add data integrity principle to CLAUDE.md coding conventions Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CLAUDE.md b/CLAUDE.md index adde85d..bdb13be 100755 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -151,3 +151,4 @@ TECHSPEC 코드 패턴을 **정확히** 따를 것. - **커밋 메시지:** Conventional Commits 형식 - **문자열:** 더블쿼트 우선 (rubocop-rails-omakase 기본) - **Skinny Controller, Fat Model:** 비즈니스 로직과 쿼리는 모델(scope, 메서드)에 두고, 컨트롤러는 요청/응답 흐름만 담당 +- **데이터 무결성은 모델이 보호한다:** 마감, 정원, 중복 등의 규칙은 반드시 모델에서 검증한다. 컨트롤러의 사전 체크는 UX용 빠른 거부일 뿐, 데이터 보호의 주체는 모델이다.