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
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
3. **Red → Green → Refactor**: 테스트 실패 → 최소 구현 → 리팩토링 순서 엄수
4. **Fixtures 활용**: `test/fixtures/*.yml`에 실제 데이터 흐름을 담아라
5. **동일 성격 검증은 하나의 테스트로 합쳐라**: 필수 필드 3개 → 테스트 1개에서 모두 검증 (필드별 분리 금지)
6. **테스트 내 인라인 로직을 대체하는 모델 메서드가 추가되면, 기존 테스트도 해당 메서드를 사용하도록 수정한다**

### 단계별 테스트 범위

Expand Down
9 changes: 4 additions & 5 deletions PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,14 +139,13 @@ bundle exec rails runner "puts 'OK'"

**Unit Tests**

- [ ] 이름 정규화: "홍 길 동" → "홍길동"
- [ ] 전화번호 정규화: "010-1234-5678" → "01012345678"
- [ ] 필수 필드 누락 시 에러
- [x] 이름 정규화: "홍 길 동" → "홍길동"
- [x] 전화번호 정규화: "010-1234-5678" → "01012345678"

**Integration Tests**

- [ ] 신청 폼 표시 (available 코스만, 잔여 인원 표시)
- [ ] 입력 에러 시 폼 상태 유지 + 입력값 보존
- [x] 신청 폼 표시 (available 코스만, 잔여 인원 표시)
- [x] 필수 필드 누락 제출 시 폼 상태 유지 + 입력값 보존

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

Expand Down
20 changes: 6 additions & 14 deletions TECHSPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,9 @@
- Rails 생태계에서 단일 id PK가 컨벤션이며, 복합 PK는 라우팅/association/유지보수에 마찰을 만든다
- Unique Index는 동시 요청에서도 DB가 최종적으로 중복을 차단한다
- name/phone_number 정규화 (저장 전 처리)
- name: 모든 공백 제거 ("홍 길 동" → "홍길동")
- phone: 숫자만 추출 ("010-1234-5678" → "01012345678")
- name: 모든 공백 제거 ("홍 길 동" → "홍길동"), 최대 10글자
- address: 최대 30글자
- phone: 숫자만 추출 ("010-1234-5678" → "01012345678"), 최대 11자리
- 이유: "홍길동" vs "홍 길동", "010-1234-5678" vs "01012345678" 등 동일인 우회 방지
- 적용 위치: 모델 콜백(before_validation)에서 일괄 처리
- 정원 초과 신청 방지 (동시성 대응)
Expand Down Expand Up @@ -526,21 +527,12 @@ Course 1 ──< Registration

### 7.1 데이터 정규화 (Registration)

저장 전 `before_validation` 콜백에서 처리:
`normalizes` 선언으로 처리:

```ruby
class Registration < ApplicationRecord
before_validation :normalize_name, :normalize_phone_number

private

def normalize_name
self.name = name.gsub(/\s+/, '') if name.present?
end

def normalize_phone_number
self.phone_number = phone_number.gsub(/\D/, '') if phone_number.present?
end
normalizes :name, with: ->(name) { name.gsub(/\s+/, "") }
normalizes :phone_number, with: ->(phone_number) { phone_number.gsub(/\D/, "") }
end
```

Expand Down
6 changes: 6 additions & 0 deletions app/controllers/home_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
class HomeController < ApplicationController
def show
@race = Race.first!
@courses = @race.courses.where("capacity > 0")
end
end
23 changes: 23 additions & 0 deletions app/controllers/registrations_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
class RegistrationsController < ApplicationController
def new
@course = Course.find(params[:course_id])
@registration = @course.registrations.new
end

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
end

private

def registration_params
params.require(:registration).permit(:name, :phone_number, :birth_date, :gender, :address)
end
end
4 changes: 4 additions & 0 deletions app/models/course.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
class Course < ApplicationRecord
belongs_to :race
has_many :registrations, dependent: :destroy

def remaining_slots
capacity - registrations.where(status: "applied").count
end
end
16 changes: 15 additions & 1 deletion app/models/registration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,21 @@ class Registration < ApplicationRecord
belongs_to :race
belongs_to :course

before_validation :set_race_from_course

enum :gender, { male: "male", female: "female" }

validates :name, :phone_number, :birth_date, :gender, :address, presence: true
normalizes :name, with: ->(name) { name.gsub(/\s+/, "") }
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 :birth_date, :gender, presence: true
validates :address, presence: true, length: { maximum: 30 }

private

def set_race_from_course
self.race = course.race if course.present? && race.blank?
end
end
11 changes: 11 additions & 0 deletions app/views/home/show.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<h1><%= @race.name %></h1>

<div class="courses">
<% @courses.each do |course| %>
<div data-course-id="<%= course.id %>">
<h2><%= course.name %></h2>
<p class="remaining-slots">잔여: <%= course.remaining_slots %>명</p>
<%= link_to "신청하기", new_course_registration_path(course) %>
</div>
<% end %>
</div>
42 changes: 42 additions & 0 deletions app/views/registrations/new.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<h1><%= @course.name %> 신청</h1>

<% if @registration.errors.any? %>
<div class="field-errors">
<ul>
<% @registration.errors.full_messages.each do |message| %>
<li><%= message %></li>
<% end %>
</ul>
</div>
<% end %>

<%= form_with model: @registration, url: course_registrations_path(@course) do |f| %>
<div>
<%= f.label :name, "이름" %>
<%= f.text_field :name, maxlength: 10 %>
</div>

<div>
<%= f.label :phone_number, "전화번호" %>
<%= f.text_field :phone_number, maxlength: 13 %>
</div>

<div>
<%= f.label :birth_date, "생년월일" %>
<%= f.date_field :birth_date %>
</div>

<div>
<%= f.label :gender, "성별" %>
<%= f.select :gender, [["남성", "male"], ["여성", "female"]], prompt: "선택해주세요" %>
</div>

<div>
<%= f.label :address, "주소" %>
<%= f.text_area :address, maxlength: 30 %>
</div>

<div>
<%= f.submit "신청하기" %>
</div>
<% end %>
7 changes: 5 additions & 2 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@
# get "manifest" => "rails/pwa#manifest", as: :pwa_manifest
# get "service-worker" => "rails/pwa#service_worker", as: :pwa_service_worker

# Defines the root path route ("/")
# root "posts#index"
root "home#show"

resources :courses, only: [] do
resources :registrations, only: [ :new, :create ]
end
end
57 changes: 57 additions & 0 deletions test/integration/registration_form_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
require "test_helper"

class RegistrationFormTest < ActionDispatch::IntegrationTest
test "home page shows available courses with registration links" do
get root_path

assert_response :success

[ courses(:five_km), courses(:ten_km), courses(:half) ].each do |course|
assert_select "[data-course-id='#{course.id}'] a[href=?]", new_course_registration_path(course)
end
end

test "home page hides courses with zero capacity" do
get root_path

assert_select "[data-course-id='#{courses(:full).id}']", count: 0
end

test "home page displays remaining slots for each available course" do
get root_path

five_km = courses(:five_km)

assert_select "[data-course-id='#{five_km.id}'] .remaining-slots",
text: /#{five_km.remaining_slots}/
end

test "registration form renders input fields for the selected course" do
course = courses(:five_km)

get new_course_registration_path(course)

assert_response :success
assert_select "form[action=?]", course_registrations_path(course)
assert_select "input[name='registration[name]']"
assert_select "input[name='registration[phone_number]']"
assert_select "input[name='registration[birth_date]']"
assert_select "select[name='registration[gender]']"
assert_select "textarea[name='registration[address]']"
end

test "submitting with missing fields re-renders form with validation errors and preserves input" do
course = courses(:five_km)
kept_name = "김철수"

assert_no_difference "Registration.count" do
post course_registrations_path(course), params: {
registration: { name: kept_name, phone_number: "", birth_date: "", gender: "", address: "" }
}
end

assert_response :unprocessable_entity
assert_select "input[name='registration[name]'][value=?]", kept_name
assert_select ".field-errors"
end
end
5 changes: 5 additions & 0 deletions test/models/course_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,9 @@ class CourseTest < ActiveSupport::TestCase
course = courses(:five_km)
assert_includes course.registrations, registrations(:hong_5km)
end

test "remaining_slots returns capacity minus applied registrations" do
course = courses(:five_km)
assert_equal course.capacity - 1, course.remaining_slots
end
end
33 changes: 33 additions & 0 deletions test/models/registration_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,39 @@ class RegistrationTest < ActiveSupport::TestCase
assert_equal courses(:five_km), registration.course
end

test "normalizes name by removing spaces" do
registration = registrations(:hong_5km)
registration.name = "홍 길 동"
assert_equal "홍길동", registration.name
end

test "normalizes phone_number by removing non-digits" do
registration = registrations(:hong_5km)
registration.phone_number = "010-1234-5678"
assert_equal "01012345678", registration.phone_number
end

test "rejects name longer than 10 characters" do
registration = registrations(:hong_5km)
registration.name = "가" * 11
assert_not registration.valid?
assert_includes registration.errors[:name], "is too long (maximum is 10 characters)"
end

test "rejects phone_number longer than 11 digits" do
registration = registrations(:hong_5km)
registration.phone_number = "0" * 12
assert_not registration.valid?
assert_includes registration.errors[:phone_number], "is too long (maximum is 11 characters)"
end

test "rejects address longer than 30 characters" do
registration = registrations(:hong_5km)
registration.address = "가" * 31
assert_not registration.valid?
assert_includes registration.errors[:address], "is too long (maximum is 30 characters)"
end

test "requires name, phone_number, birth_date, gender, and address" do
registration = registrations(:hong_5km)
registration.name = nil
Expand Down