Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,12 @@
- Association 선언(has_many, belongs_to)은 M3 범위이므로, M2 Seed에서는 association 메서드(`race.courses`) 대신 FK 직접 참조(`race_id: race.id`)를 사용한다
- M3 완료 후 Seed를 association 방식으로 리팩토링할 수 있다

### 테스트 작성 원칙

- 동시성(Thread) 테스트는 반드시 모델 레벨(`ActiveSupport::TestCase`)에서 작성
- `ActionDispatch::IntegrationTest`에서 Thread + post 조합 금지 (`@response` 공유 충돌)
- 통합 테스트는 단일 요청의 HTTP 플로우 검증에만 사용

### 작성하지 않는 테스트

- 스키마 레벨 테스트 (컬럼 타입, 존재 여부) — 마이그레이션이 명세서 역할
Expand All @@ -83,6 +89,12 @@

## CONSTRAINTS

### Gem 사용 규칙

- 새로운 gem을 사용하기 전에 반드시 Gemfile에 존재하는지 확인
- 없으면 코드 작성 전에 gem 추가 필요 여부를 먼저 알려줄 것
- 기존 의존성만으로 해결 가능한지 우선 검토

- [ ] 테스트 없이 프로덕션 코드 작성 금지 (단, Association 선언은 선언적 코드이므로 예외)
- [ ] 한 번에 여러 기능 구현 금지
- [ ] TECHSPEC.md에 명시되지 않은 기술 스택 도입 금지
Expand Down Expand Up @@ -137,3 +149,4 @@ TECHSPEC 코드 패턴을 **정확히** 따를 것.
- **네이밍:** snake_case (Ruby)
- **커밋 메시지:** Conventional Commits 형식
- **문자열:** 더블쿼트 우선 (rubocop-rails-omakase 기본)
- **Skinny Controller, Fat Model:** 비즈니스 로직과 쿼리는 모델(scope, 메서드)에 두고, 컨트롤러는 요청/응답 흐름만 담당
32 changes: 20 additions & 12 deletions PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@
### `commit` - 완료 처리

1. **Update PLAN.md**: 완료된 항목에 체크박스 표시
2. **Commit**: PLAN.md 변경을 포함하여 커밋한다
2. **Update Commits**: 해당 마일스톤의 `- Commits:` 줄에 커밋 해시(short)를 추가한다 (기존 값이 있으면 쉼표로 이어붙임)
3. **Commit**: PLAN.md 변경을 포함하여 커밋한다

### `verify` - 마일스톤 완료 후 수동 검증

Expand Down Expand Up @@ -103,7 +104,7 @@ bundle exec rails runner "puts 'OK'"

**완료 조건:** `rails db:migrate db:seed` 성공, 스키마 확인

- Commits:
- Commits: f9f8837, e3198e6, bc21012, 4739793, 399882d, 1645f88

---

Expand All @@ -127,7 +128,7 @@ bundle exec rails runner "puts 'OK'"

**완료 조건:** 모든 유닛 테스트 통과

- Commits:
- Commits: 1efce8a, 8ff35e0, 0d0e543, ea6caee, a30813c

---

Expand All @@ -149,7 +150,7 @@ bundle exec rails runner "puts 'OK'"

**완료 조건:** 신청 폼 동작, 정규화 적용, 에러 시 입력값 보존

- Commits:
- Commits: 74d04d1, b27ac56, b133a69, 1598721, 30734ad, 95653ba, a705d01

---

Expand All @@ -161,17 +162,20 @@ bundle exec rails runner "puts 'OK'"

**Unit Tests**

- [ ] Course#full? - applied 수 >= capacity 시 true
- [ ] Course#available? - 마감 + 정원 조합 검증
- [x] Course#full? - applied 수 >= capacity 시 true
- [x] Course#available? - 마감 + 정원 조합 검증

**Integration Tests (P0)**
**Concurrency Tests (P0)** ← Issues #1 참조

- [x] 정원 1명, 동시 신청 2건 → 1건만 성공

**Integration Tests**

- [ ] 정원 1명, 동시 신청 2건 → 1건만 성공
- [ ] 정원 초과 시 에러: "선택하신 코스의 정원이 마감되었습니다."
- [x] 정원 초과 시 에러: "선택하신 코스의 정원이 마감되었습니다."

**완료 조건:** 동시성 테스트 통과, 정원 초과 차단

- Commits:
- Commits: 1601373, 22a4463, 486c4a4, 7355866

---

Expand All @@ -185,9 +189,12 @@ bundle exec rails runner "puts 'OK'"

- [ ] 동일 (race_id, name, phone_number) 중복 저장 시 에러

**Integration Tests (P0)**
**Concurrency Tests (P0)** ← Issues #1 참조

- [ ] 동일 정보로 동시 신청 2건 → 1건만 성공

**Integration Tests**

- [ ] 중복 시 에러: "이미 동일한 이름과 전화번호로 신청된 내역이 있습니다."

**완료 조건:** 중복 신청 차단, 동시성 테스트 통과
Expand Down Expand Up @@ -408,6 +415,7 @@ bundle exec rails runner "puts 'OK'"

| # | 마일스톤 | 내용 | 상태 |
| --- | -------- | ---- | ---- |
| | | | |
| 1 | M5, M6 | 동시성 테스트: `ActionDispatch::IntegrationTest`의 `post`는 `@response` 등 인스턴스 변수를 공유하여 thread-safe하지 않음. 모델 레벨에서 `Thread` + `ActiveRecord::Base.connection_pool.with_connection`으로 별도 커넥션을 확보하여 테스트한다 (TECHSPEC § 8.3 참조) | 적용 |
| 2 | M5 | `save` vs `create!`: 컨트롤러에서 `create_registration!`(`create!` 사용)로 전환 시, 예외 발생하면 `@registration`에 할당이 안 되어 뷰 렌더링 실패. `rescue ActiveRecord::RecordInvalid => e`에서 `e.record`로 실패한 객체를 꺼내 `@registration`에 할당하여 해결 | 적용 |

---
2 changes: 1 addition & 1 deletion app/controllers/home_controller.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
class HomeController < ApplicationController
def show
@race = Race.first!
@race = Race.upcoming.first!
@courses = @race.courses.where("capacity > 0")
end
end
14 changes: 7 additions & 7 deletions app/controllers/registrations_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,13 @@ def new

def create
@course = Course.find(params[:course_id])
@registration = @course.registrations.new(registration_params)

if @registration.save
redirect_to root_path
else
render :new, status: :unprocessable_entity
end
@registration = @course.create_registration!(registration_params)
redirect_to root_path
rescue Course::CapacityExceededError => e
redirect_to new_course_registration_path(@course), alert: e.message
rescue ActiveRecord::RecordInvalid => e
@registration = e.record
render :new, status: :unprocessable_entity
end

private
Expand Down
22 changes: 22 additions & 0 deletions app/models/course.rb
Original file line number Diff line number Diff line change
@@ -1,8 +1,30 @@
class Course < ApplicationRecord
class CapacityExceededError < StandardError; end

belongs_to :race
has_many :registrations, dependent: :destroy

def remaining_slots
capacity - registrations.where(status: "applied").count
end

def full?
remaining_slots <= 0
end

def available?
!full? && race.registration_deadline > Time.current
end

def create_registration!(params)
Course.transaction do
lock!

if full?
raise CapacityExceededError, "선택하신 코스의 정원이 마감되었습니다."
end

registrations.create!(params)
end
end
end
2 changes: 2 additions & 0 deletions app/models/race.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
class Race < ApplicationRecord
has_many :courses, dependent: :destroy
has_many :registrations, dependent: :destroy

scope :upcoming, -> { where("registration_deadline > ?", Time.current).order(:event_date) }
end
2 changes: 1 addition & 1 deletion app/models/registration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ class Registration < ApplicationRecord
normalizes :phone_number, with: ->(phone_number) { phone_number.gsub(/\D/, "") }

validates :name, presence: true, length: { maximum: 10 }
validates :phone_number, presence: true, length: { maximum: 11 }
validates :phone_number, presence: true, length: { is: 11 }
validates :birth_date, :gender, presence: true
validates :address, presence: true, length: { maximum: 30 }

Expand Down
6 changes: 6 additions & 0 deletions app/views/layouts/application.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@
</head>

<body>
<% if flash[:alert] %>
<div class="flash-alert"><%= flash[:alert] %></div>
<% end %>
<% if flash[:notice] %>
<div class="flash-notice"><%= flash[:notice] %></div>
<% end %>
<%= yield %>
</body>
</html>
14 changes: 14 additions & 0 deletions test/fixtures/courses.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,17 @@ full:
capacity: 0
fee: 70000
start_time: "07:30:00"

one_slot:
race: marathon_2026
name: "체험"
capacity: 1
fee: 10000
start_time: "10:00:00"

closed_five_km:
race: closed_race
name: "5km"
capacity: 200
fee: 20000
start_time: "09:00:00"
6 changes: 6 additions & 0 deletions test/fixtures/races.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,9 @@ marathon_2026:
event_date: <%= 3.months.from_now %>
location: "서울 여의도공원"
registration_deadline: <%= 2.months.from_now %>

closed_race:
name: "부산마라톤 2025"
event_date: <%= 1.month.ago %>
location: "부산 해운대"
registration_deadline: <%= 2.months.ago %>
20 changes: 20 additions & 0 deletions test/integration/layout_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
require "test_helper"

class LayoutTest < ActionDispatch::IntegrationTest
test "flash alert is rendered in layout" do
course = courses(:full)

post course_registrations_path(course), params: {
registration: {
name: "테스트",
phone_number: "01099999999",
birth_date: "1990-01-01",
gender: "male",
address: "서울시 강남구"
}
}

follow_redirect!
assert_select ".flash-alert", "선택하신 코스의 정원이 마감되었습니다."
end
end
17 changes: 17 additions & 0 deletions test/integration/registration_form_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,23 @@ class RegistrationFormTest < ActionDispatch::IntegrationTest
assert_select "textarea[name='registration[address]']"
end

test "registration fails with error when course is full" do
course = courses(:full)

post course_registrations_path(course), params: {
registration: {
name: "테스트",
phone_number: "01099999999",
birth_date: "1990-01-01",
gender: "male",
address: "서울시 강남구"
}
}

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 = "김철수"
Expand Down
31 changes: 31 additions & 0 deletions test/models/course_capacity_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
require "test_helper"

class CourseCapacityTest < ActiveSupport::TestCase
test "concurrent registrations with capacity 1 allows only 1 success" do
course = courses(:one_slot)
results = Concurrent::Array.new

threads = 2.times.map do |i|
Thread.new do
ActiveRecord::Base.connection_pool.with_connection do
course.create_registration!(
name: "신청자#{i}",
phone_number: "0101234000#{i}",
birth_date: "1990-01-01",
gender: "male",
address: "서울시 강남구"
)
results << :success
rescue Course::CapacityExceededError
results << :capacity_exceeded
end
end
end

threads.each(&:join)

assert_equal 1, results.count(:success)
assert_equal 1, results.count(:capacity_exceeded)
assert_equal 1, course.registrations.where(status: "applied").count
end
end
25 changes: 25 additions & 0 deletions test/models/course_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,29 @@ class CourseTest < ActiveSupport::TestCase
course = courses(:five_km)
assert_equal course.capacity - 1, course.remaining_slots
end

test "full? returns true when applied count >= capacity" do
course = courses(:full)
assert course.full?
end

test "full? returns false when applied count < capacity" do
course = courses(:five_km)
assert_not course.full?
end

test "available? returns true when not full and before deadline" do
course = courses(:five_km)
assert course.available?
end

test "available? returns false when full" do
course = courses(:full)
assert_not course.available?
end

test "available? returns false when registration deadline has passed" do
course = courses(:closed_five_km)
assert_not course.available?
end
end
9 changes: 7 additions & 2 deletions test/models/registration_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,16 @@ class RegistrationTest < ActiveSupport::TestCase
assert_includes registration.errors[:name], "is too long (maximum is 10 characters)"
end

test "rejects phone_number longer than 11 digits" do
test "rejects phone_number that is not exactly 11 digits" do
registration = registrations(:hong_5km)

registration.phone_number = "0" * 9
assert_not registration.valid?
assert_includes registration.errors[:phone_number], "is the wrong length (should be 11 characters)"

registration.phone_number = "0" * 12
assert_not registration.valid?
assert_includes registration.errors[:phone_number], "is too long (maximum is 11 characters)"
assert_includes registration.errors[:phone_number], "is the wrong length (should be 11 characters)"
end

test "rejects address longer than 30 characters" do
Expand Down