From 7e29ea1d0c0df1c2b2dba86659b21fa0d6d7303e Mon Sep 17 00:00:00 2001 From: nacchan Date: Thu, 24 Jul 2025 11:26:30 +0900 Subject: [PATCH 01/12] =?UTF-8?q?test:=20News=E3=83=A2=E3=83=87=E3=83=AB?= =?UTF-8?q?=E3=81=AE=E3=83=90=E3=83=AA=E3=83=87=E3=83=BC=E3=82=B7=E3=83=A7?= =?UTF-8?q?=E3=83=B3=E3=83=BB=E3=82=B9=E3=82=B3=E3=83=BC=E3=83=97=E3=81=AE?= =?UTF-8?q?=E3=83=86=E3=82=B9=E3=83=88=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- spec/models/news_spec.rb | 64 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 63 insertions(+), 1 deletion(-) diff --git a/spec/models/news_spec.rb b/spec/models/news_spec.rb index 0063aaf42..836ec814f 100644 --- a/spec/models/news_spec.rb +++ b/spec/models/news_spec.rb @@ -1,5 +1,67 @@ require 'rails_helper' RSpec.describe News, type: :model do - pending "add some examples to (or delete) #{__FILE__}" + describe 'バリデーション' do + let(:news) { build(:news) } + + it '有効なファクトリーを持つ' do + expect(news).to be_valid + end + + describe 'title' do + it 'presence: true' do + news.title = nil + expect(news).not_to be_valid + expect(news.errors[:title]).not_to be_empty + end + end + + describe 'url' do + it 'presence: true' do + news.url = nil + expect(news).not_to be_valid + expect(news.errors[:url]).not_to be_empty + end + + it 'uniqueness: true' do + create(:news, url: 'https://example.com/test') + duplicate_news = build(:news, url: 'https://example.com/test') + expect(duplicate_news).not_to be_valid + expect(duplicate_news.errors[:url]).not_to be_empty + end + + it 'URL形式であること' do + news.url = 'invalid-url' + expect(news).not_to be_valid + expect(news.errors[:url]).not_to be_empty + end + + it 'HTTPSとHTTPを許可する' do + news.url = 'https://example.com' + expect(news).to be_valid + + news.url = 'http://example.com' + expect(news).to be_valid + end + end + + describe 'published_at' do + it 'presence: true' do + news.published_at = nil + expect(news).not_to be_valid + expect(news.errors[:published_at]).not_to be_empty + end + end + end + + describe 'スコープ' do + describe '.recent' do + it '公開日時の降順で並び替える' do + old_news = create(:news, published_at: 2.days.ago) + new_news = create(:news, published_at: 1.day.ago) + + expect(News.recent).to eq([new_news, old_news]) + end + end + end end From b0f43659eb9ac75ef8940a2df4eccf57fc973d85 Mon Sep 17 00:00:00 2001 From: nacchan Date: Thu, 24 Jul 2025 12:19:09 +0900 Subject: [PATCH 02/12] =?UTF-8?q?test:=20News=E3=83=A2=E3=83=87=E3=83=AB?= =?UTF-8?q?=E3=82=B9=E3=83=9A=E3=83=83=E3=82=AF=E3=81=AE=E8=AA=AC=E6=98=8E?= =?UTF-8?q?=E3=82=92=E6=97=A5=E6=9C=AC=E8=AA=9E=E3=81=A7=E7=B5=B1=E4=B8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- spec/models/news_spec.rb | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/spec/models/news_spec.rb b/spec/models/news_spec.rb index 836ec814f..45a01f29a 100644 --- a/spec/models/news_spec.rb +++ b/spec/models/news_spec.rb @@ -9,7 +9,7 @@ end describe 'title' do - it 'presence: true' do + it 'タイトルが空の場合は無効になる' do news.title = nil expect(news).not_to be_valid expect(news.errors[:title]).not_to be_empty @@ -17,13 +17,13 @@ end describe 'url' do - it 'presence: true' do + it 'URL が空の場合は無効になる' do news.url = nil expect(news).not_to be_valid expect(news.errors[:url]).not_to be_empty end - it 'uniqueness: true' do + it 'URL が重複している場合は無効になる' do create(:news, url: 'https://example.com/test') duplicate_news = build(:news, url: 'https://example.com/test') expect(duplicate_news).not_to be_valid @@ -46,7 +46,7 @@ end describe 'published_at' do - it 'presence: true' do + it '公開日時が空の場合は無効になる' do news.published_at = nil expect(news).not_to be_valid expect(news.errors[:published_at]).not_to be_empty From 052cfe2a9311ab3252f04874751d765206883ee1 Mon Sep 17 00:00:00 2001 From: nacchan Date: Fri, 25 Jul 2025 09:17:08 +0900 Subject: [PATCH 03/12] =?UTF-8?q?test:=20news=20Rake=E3=82=BF=E3=82=B9?= =?UTF-8?q?=E3=82=AF=E3=81=AERSpec=E3=82=92=E8=BF=BD=E5=8A=A0=20&=20fetch/?= =?UTF-8?q?import=E3=82=BF=E3=82=B9=E3=82=AF=E3=82=92=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - news:fetchタスクでfileスキーム/ローカルパスをsafe_openで許可(テスト用RSSの読み込みに対応) - YAML書き込み・ログ出力のパスをENV['NEWS_YAML_PATH']で上書き可能なyaml_pathに統一 - ソート後に全アイテムへ1からの連番IDを再付与 - news:import_from_yamlタスクもENV['NEWS_YAML_PATH']に対応 --- lib/tasks/fetch_news.rake | 30 ++++++------- lib/tasks/import_news.rake | 5 ++- spec/tasks/news_rake_spec.rb | 86 ++++++++++++++++++++++++++++++++++++ 3 files changed, 103 insertions(+), 18 deletions(-) create mode 100644 spec/tasks/news_rake_spec.rb diff --git a/lib/tasks/fetch_news.rake b/lib/tasks/fetch_news.rake index 415d5df25..00de49275 100644 --- a/lib/tasks/fetch_news.rake +++ b/lib/tasks/fetch_news.rake @@ -7,8 +7,8 @@ require 'active_support/broadcast_logger' def safe_open(url) uri = URI.parse(url) + return File.read(url) if uri.scheme.nil? || uri.scheme == 'file' raise "不正なURLです: #{url}" unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS) - Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https') do |http| request = Net::HTTP::Get.new(uri) response = http.request(request) @@ -26,25 +26,19 @@ namespace :news do logger.info('==== START news:fetch ====') - # 既存の news.yml を読み込み - yaml_path = Rails.root.join('db', 'news.yml') + # YAML出力先を環境変数で上書きできるようにする + yaml_path = ENV['NEWS_YAML_PATH'] ? Pathname.new(ENV['NEWS_YAML_PATH']) : Rails.root.join('db', 'news.yml') + feed_urls = ENV['NEWS_RSS_PATH'] ? [ENV['NEWS_RSS_PATH']] : + (Rails.env.test? || Rails.env.staging? ? + [Rails.root.join('spec', 'fixtures', 'sample_news.rss').to_s] : + ['https://news.coderdojo.jp/feed/']) + existing_news = if File.exist?(yaml_path) YAML.safe_load(File.read(yaml_path), permitted_classes: [Time], aliases: true)['news'] || [] else [] end - # テスト/ステージング環境ではサンプルファイル、本番は実サイトのフィード - feed_urls = if Rails.env.test? || Rails.env.staging? - [Rails.root.join('spec', 'fixtures', 'sample_news.rss').to_s] - else - [ - 'https://news.coderdojo.jp/feed/' - # 必要に応じて他 Dojo の RSS もここに追加可能 - # 'https://coderdojotokyo.org/feed', - ] - end - # RSS 取得&パース new_items = feed_urls.flat_map do |url| logger.info("Fetching RSS → #{url}") @@ -107,7 +101,11 @@ namespace :news do Time.parse(item['published_at']) }.reverse - File.open('db/news.yml', 'w') do |f| + sorted_items.each_with_index do |item, index| + item['id'] = index + 1 + end + + File.open(yaml_path, 'w') do |f| formatted_items = sorted_items.map do |item| { 'id' => item['id'], @@ -120,7 +118,7 @@ namespace :news do f.write({ 'news' => formatted_items }.to_yaml) end - logger.info("✅ Wrote #{sorted_items.size} items to db/news.yml (#{truly_new_items_sorted.size} new, #{updated_items.size} updated)") + logger.info("✅ Wrote #{sorted_items.size} items to #{yaml_path} (#{truly_new_items_sorted.size} new, #{updated_items.size} updated)") logger.info('==== END news:fetch ====') end end diff --git a/lib/tasks/import_news.rake b/lib/tasks/import_news.rake index bebe76611..ba73110df 100644 --- a/lib/tasks/import_news.rake +++ b/lib/tasks/import_news.rake @@ -1,9 +1,10 @@ require 'yaml' namespace :news do - desc 'db/news.yml を読み込んで News テーブルを upsert する' + desc 'db/news.yml (またはENV指定のYAML)を読み込んで News テーブルを upsert する' task import_from_yaml: :environment do - yaml_path = Rails.root.join('db', 'news.yml') + # ENVで上書き可能にする(なければデフォルト db/news.yml) + yaml_path = ENV['NEWS_YAML_PATH'] ? Pathname.new(ENV['NEWS_YAML_PATH']) : Rails.root.join('db', 'news.yml') raw = YAML.safe_load(File.read(yaml_path), permitted_classes: [Time], aliases: true) # entries を計算 diff --git a/spec/tasks/news_rake_spec.rb b/spec/tasks/news_rake_spec.rb new file mode 100644 index 000000000..2bd586021 --- /dev/null +++ b/spec/tasks/news_rake_spec.rb @@ -0,0 +1,86 @@ +require 'rails_helper' +require 'rake' +require 'yaml' + +RSpec.describe 'news Rakeタスク', type: :task do + before { Rails.application.load_tasks } + before { allow(Rails.env).to receive(:test?).and_return(true) } + + # テスト用に tmp/news.yml を使う + let(:yaml_path) { Rails.root.join('tmp', 'news.yml') } + let(:fetch_task) { Rake::Task['news:fetch'] } + let(:import_task) { Rake::Task['news:import_from_yaml'] } + let(:yaml_content) { YAML.safe_load(File.read(yaml_path), permitted_classes: [Time]) } + + around do |example| + # テスト前後に一度だけ tmp/news.yml をクリア + File.delete(yaml_path) if File.exist?(yaml_path) + example.run + File.delete(yaml_path) if File.exist?(yaml_path) + end + + describe 'news:fetch タスク' do + before do + ENV['NEWS_YAML_PATH'] = yaml_path.to_s + ENV['NEWS_RSS_PATH'] = Rails.root.join('spec', 'fixtures', 'sample_news.rss').to_s + fetch_task.reenable + end + + after do + ENV.delete('NEWS_YAML_PATH') + ENV.delete('NEWS_RSS_PATH') + end + + it 'サンプルRSSからニュースを取得し YAML に書き込む' do + expect { fetch_task.invoke }.not_to raise_error + expect(File.exist?(yaml_path)).to be true + expect(yaml_content['news']).to be_an(Array) + expect(yaml_content['news'].size).to eq(3) + end + + it 'ID が 1 から連番で付与される' do + fetch_task.invoke + ids = yaml_content['news'].map { |item| item['id'] } + expect(ids).to eq([1, 2, 3]) + end + + it '公開日時で降順ソートされる' do + fetch_task.invoke + dates = yaml_content['news'].map { |item| Time.parse(item['published_at']) } + expect(dates).to eq(dates.sort.reverse) + end + end + + describe 'news:import_from_yaml タスク' do + let(:news_data) do + { + 'news' => [ + { 'id' => 1, 'url' => 'https://example.com/test1', 'title' => 'テスト記事1', 'published_at' => '2025-01-01T10:00:00Z' }, + { 'id' => 2, 'url' => 'https://example.com/test2', 'title' => 'テスト記事2', 'published_at' => '2025-01-02T10:00:00Z' } + ] + } + end + + before do + ENV['NEWS_YAML_PATH'] = yaml_path.to_s + File.write(yaml_path, news_data.to_yaml) + import_task.reenable + end + + after do + ENV.delete('NEWS_YAML_PATH') + end + + it 'YAML ファイルから News レコードを新規作成する' do + expect { import_task.invoke }.to change(News, :count).by(2) + expect(News.find_by(url: news_data['news'][0]['url']).title).to eq('テスト記事1') + expect(News.find_by(url: news_data['news'][1]['url']).title).to eq('テスト記事2') + end + + it '既存レコードがあれば属性を更新する' do + create(:news, url: news_data['news'][0]['url'], title: '古いタイトル') + expect { import_task.invoke }.to change(News, :count).by(1) + expect(News.find_by(url: news_data['news'][0]['url']).title).to eq('テスト記事1') + end + end +end From b35ad64d7d89cc9088b77c0a5101ea220e797d6e Mon Sep 17 00:00:00 2001 From: "claude[bot]" <209825114+claude[bot]@users.noreply.github.com> Date: Fri, 25 Jul 2025 00:31:38 +0000 Subject: [PATCH 04/12] =?UTF-8?q?test:=20news:fetch=E3=82=BF=E3=82=B9?= =?UTF-8?q?=E3=82=AF=E3=81=AE=E3=82=A8=E3=83=A9=E3=83=BC=E3=83=8F=E3=83=B3?= =?UTF-8?q?=E3=83=89=E3=83=AA=E3=83=B3=E3=82=B0=E7=94=A8RSpec=E3=82=92?= =?UTF-8?q?=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ネットワークエラー(接続タイムアウト、HTTPエラー、不正なURL)のテスト - 不正なRSS(無効なXML、空のフィード、必須フィールドの欠落)のテスト - 破損したYAMLファイル(構文エラー、不正な構造、許可されていないクラス)のテスト - 複数エラーの同時発生とエラーリカバリーのテストも含む Co-authored-by: nacchan99 --- spec/tasks/news_fetch_error_handling_spec.rb | 264 +++++++++++++++++++ 1 file changed, 264 insertions(+) create mode 100644 spec/tasks/news_fetch_error_handling_spec.rb diff --git a/spec/tasks/news_fetch_error_handling_spec.rb b/spec/tasks/news_fetch_error_handling_spec.rb new file mode 100644 index 000000000..574b6df2d --- /dev/null +++ b/spec/tasks/news_fetch_error_handling_spec.rb @@ -0,0 +1,264 @@ +require 'rails_helper' +require 'rake' +require 'yaml' +require 'net/http' + +RSpec.describe 'news:fetch エラーハンドリング', type: :task do + before { Rails.application.load_tasks } + before { allow(Rails.env).to receive(:test?).and_return(true) } + + let(:yaml_path) { Rails.root.join('tmp', 'error_test_news.yml') } + let(:fetch_task) { Rake::Task['news:fetch'] } + let(:logger_mock) { instance_double(ActiveSupport::BroadcastLogger) } + + before do + ENV['NEWS_YAML_PATH'] = yaml_path.to_s + fetch_task.reenable + + # ロガーのモック設定 + allow(ActiveSupport::BroadcastLogger).to receive(:new).and_return(logger_mock) + allow(logger_mock).to receive(:info) + allow(logger_mock).to receive(:warn) + end + + after do + ENV.delete('NEWS_YAML_PATH') + ENV.delete('NEWS_RSS_PATH') + File.delete(yaml_path) if File.exist?(yaml_path) + end + + describe 'ネットワークエラーのハンドリング' do + context 'safe_open がネットワークエラーで例外を投げる場合' do + before do + ENV['NEWS_RSS_PATH'] = 'https://example.com/feed.rss' + allow_any_instance_of(Object).to receive(:safe_open).and_raise(Net::OpenTimeout, '接続タイムアウト') + end + + it 'エラーをログに記録し、処理を継続する' do + expect(logger_mock).to receive(:warn).with(/⚠️ Failed to fetch .+: 接続タイムアウト/) + expect { fetch_task.invoke }.not_to raise_error + + # 空の news.yml が作成される + expect(File.exist?(yaml_path)).to be true + yaml_content = YAML.safe_load(File.read(yaml_path), permitted_classes: [Time]) + expect(yaml_content['news']).to eq([]) + end + end + + context 'HTTPエラーレスポンスの場合' do + before do + ENV['NEWS_RSS_PATH'] = 'https://example.com/feed.rss' + allow_any_instance_of(Object).to receive(:safe_open).and_raise(Net::HTTPServerException, '500 Internal Server Error') + end + + it 'エラーをログに記録し、処理を継続する' do + expect(logger_mock).to receive(:warn).with(/⚠️ Failed to fetch .+: 500 Internal Server Error/) + expect { fetch_task.invoke }.not_to raise_error + end + end + + context '不正なURLの場合' do + before do + ENV['NEWS_RSS_PATH'] = 'https://example.com/feed.rss' + allow_any_instance_of(Object).to receive(:safe_open).and_raise('不正なURLです: https://example.com/feed.rss') + end + + it 'エラーをログに記録し、処理を継続する' do + expect(logger_mock).to receive(:warn).with(/⚠️ Failed to fetch .+: 不正なURLです/) + expect { fetch_task.invoke }.not_to raise_error + end + end + end + + describe '不正なRSSのハンドリング' do + context 'RSS::Parser.parse が失敗する場合' do + before do + ENV['NEWS_RSS_PATH'] = 'https://example.com/feed.rss' + + # safe_open は成功するが、不正なXMLを返す + allow_any_instance_of(Object).to receive(:safe_open).and_return('not valid rss') + end + + it 'エラーをログに記録し、処理を継続する' do + expect(logger_mock).to receive(:warn).with(/⚠️ Failed to fetch .+: /) + expect { fetch_task.invoke }.not_to raise_error + + # 空の news.yml が作成される + expect(File.exist?(yaml_path)).to be true + yaml_content = YAML.safe_load(File.read(yaml_path), permitted_classes: [Time]) + expect(yaml_content['news']).to eq([]) + end + end + + context '空のRSSフィードの場合' do + before do + ENV['NEWS_RSS_PATH'] = 'https://example.com/feed.rss' + + # 有効だが空のRSSフィード + empty_rss = <<~RSS + + + + Empty Feed + Empty RSS Feed + https://example.com + + + RSS + + allow_any_instance_of(Object).to receive(:safe_open).and_return(empty_rss) + end + + it '空の配列として処理し、エラーにならない' do + expect { fetch_task.invoke }.not_to raise_error + + yaml_content = YAML.safe_load(File.read(yaml_path), permitted_classes: [Time]) + expect(yaml_content['news']).to eq([]) + end + end + + context 'RSSアイテムに必須フィールドが欠けている場合' do + before do + ENV['NEWS_RSS_PATH'] = 'https://example.com/feed.rss' + + # linkやpubDateが欠けているRSS + invalid_rss = <<~RSS + + + + Invalid Feed + Invalid RSS Feed + https://example.com + + タイトルのみの記事 + + + + + RSS + + allow_any_instance_of(Object).to receive(:safe_open).and_return(invalid_rss) + end + + it 'エラーをログに記録し、処理を継続する' do + expect(logger_mock).to receive(:warn).with(/⚠️ Failed to fetch .+/) + expect { fetch_task.invoke }.not_to raise_error + end + end + end + + describe '破損したYAMLファイルのハンドリング' do + context '既存のYAMLファイルが破損している場合' do + before do + ENV['NEWS_RSS_PATH'] = Rails.root.join('spec', 'fixtures', 'sample_news.rss').to_s + + # 破損したYAMLファイルを作成 + File.write(yaml_path, "invalid yaml content:\n - broken\n indentation:\n - here") + end + + it 'YAML読み込みエラーが発生し、タスクが失敗する' do + # YAML.safe_load のエラーは rescue されないため、タスク全体が失敗する + expect { fetch_task.invoke }.to raise_error(Psych::SyntaxError) + end + end + + context '既存のYAMLファイルが不正な構造の場合' do + before do + ENV['NEWS_RSS_PATH'] = Rails.root.join('spec', 'fixtures', 'sample_news.rss').to_s + + # 不正な構造のYAMLファイル(newsキーがない) + File.write(yaml_path, { 'invalid_key' => [{ 'id' => 1 }] }.to_yaml) + end + + it '空の配列として扱い、処理を継続する' do + expect { fetch_task.invoke }.not_to raise_error + + # 新しいデータで上書きされる + yaml_content = YAML.safe_load(File.read(yaml_path), permitted_classes: [Time]) + expect(yaml_content['news']).to be_an(Array) + expect(yaml_content['news'].size).to be > 0 + end + end + + context '許可されていないクラスを含むYAMLファイルの場合' do + before do + ENV['NEWS_RSS_PATH'] = Rails.root.join('spec', 'fixtures', 'sample_news.rss').to_s + + # DateTimeオブジェクトを含むYAML(Timeのみ許可されている) + yaml_content = { + 'news' => [ + { + 'id' => 1, + 'url' => 'https://example.com/test', + 'title' => 'テスト', + 'published_at' => DateTime.now + } + ] + } + + # 強制的にDateTimeオブジェクトを含むYAMLを作成 + File.write(yaml_path, yaml_content.to_yaml.gsub('!ruby/object:DateTime', '!ruby/object:DateTime')) + end + + it 'YAML読み込みエラーが発生し、タスクが失敗する' do + expect { fetch_task.invoke }.to raise_error(Psych::DisallowedClass) + end + end + end + + describe '複数のエラーが同時に発生する場合' do + context '複数のRSSフィードで異なるエラーが発生する場合' do + before do + # 複数のフィードURLを環境変数経由では設定できないため、 + # デフォルトの動作をオーバーライドする + allow(Rails.env).to receive(:test?).and_return(false) + allow(Rails.env).to receive(:staging?).and_return(false) + ENV.delete('NEWS_RSS_PATH') + + # 最初のフィードはネットワークエラー + allow_any_instance_of(Object).to receive(:safe_open) + .with('https://news.coderdojo.jp/feed/') + .and_raise(Net::OpenTimeout, 'タイムアウト') + end + + it '各エラーをログに記録し、処理を継続する' do + expect(logger_mock).to receive(:warn).with(/⚠️ Failed to fetch .+: タイムアウト/) + expect { fetch_task.invoke }.not_to raise_error + + # 空の news.yml が作成される + expect(File.exist?(yaml_path)).to be true + yaml_content = YAML.safe_load(File.read(yaml_path), permitted_classes: [Time]) + expect(yaml_content['news']).to eq([]) + end + end + end + + describe 'エラーリカバリー' do + context 'ネットワークエラー後に再実行した場合' do + before do + ENV['NEWS_RSS_PATH'] = Rails.root.join('spec', 'fixtures', 'sample_news.rss').to_s + end + + it '正常に処理される' do + # 最初はネットワークエラー + allow_any_instance_of(Object).to receive(:safe_open).and_raise(Net::OpenTimeout, 'タイムアウト') + expect { fetch_task.invoke }.not_to raise_error + + # エラー時は空のYAMLが作成される + yaml_content = YAML.safe_load(File.read(yaml_path), permitted_classes: [Time]) + expect(yaml_content['news']).to eq([]) + + # safe_openのモックを解除して正常動作に戻す + allow_any_instance_of(Object).to receive(:safe_open).and_call_original + + # タスクを再実行可能にする + fetch_task.reenable + + # 再実行すると正常に処理される + expect { fetch_task.invoke }.not_to raise_error + yaml_content = YAML.safe_load(File.read(yaml_path), permitted_classes: [Time]) + expect(yaml_content['news'].size).to be > 0 + end + end + end +end \ No newline at end of file From 9951baecf898cb74a5b76ff77a225d04613aa546 Mon Sep 17 00:00:00 2001 From: nacchan Date: Fri, 25 Jul 2025 11:04:15 +0900 Subject: [PATCH 05/12] =?UTF-8?q?test:=20news:fetch=20=E3=82=BF=E3=82=B9?= =?UTF-8?q?=E3=82=AF=E3=81=AE=E3=82=A8=E3=83=A9=E3=83=BC=E3=83=8F=E3=83=B3?= =?UTF-8?q?=E3=83=89=E3=83=AA=E3=83=B3=E3=82=B0=E3=83=86=E3=82=B9=E3=83=88?= =?UTF-8?q?=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ネットワークエラーやRSS解析エラー時の挙動を確認 - 破損・不正なYAMLファイルのケースを追加 - ロガーをモック化してwarnログの出力を検証 --- spec/tasks/news_fetch_error_handling_spec.rb | 220 +++---------------- 1 file changed, 34 insertions(+), 186 deletions(-) diff --git a/spec/tasks/news_fetch_error_handling_spec.rb b/spec/tasks/news_fetch_error_handling_spec.rb index 574b6df2d..7bf71d555 100644 --- a/spec/tasks/news_fetch_error_handling_spec.rb +++ b/spec/tasks/news_fetch_error_handling_spec.rb @@ -7,15 +7,19 @@ before { Rails.application.load_tasks } before { allow(Rails.env).to receive(:test?).and_return(true) } - let(:yaml_path) { Rails.root.join('tmp', 'error_test_news.yml') } - let(:fetch_task) { Rake::Task['news:fetch'] } - let(:logger_mock) { instance_double(ActiveSupport::BroadcastLogger) } + let(:yaml_path) { Rails.root.join('tmp', 'error_test_news.yml') } + let(:fetch_task) { Rake::Task['news:fetch'] } + let(:logger_mock) { instance_double(ActiveSupport::BroadcastLogger) } + + around do |example| + File.delete(yaml_path) if File.exist?(yaml_path) + example.run + File.delete(yaml_path) if File.exist?(yaml_path) + end before do ENV['NEWS_YAML_PATH'] = yaml_path.to_s fetch_task.reenable - - # ロガーのモック設定 allow(ActiveSupport::BroadcastLogger).to receive(:new).and_return(logger_mock) allow(logger_mock).to receive(:info) allow(logger_mock).to receive(:warn) @@ -24,77 +28,40 @@ after do ENV.delete('NEWS_YAML_PATH') ENV.delete('NEWS_RSS_PATH') - File.delete(yaml_path) if File.exist?(yaml_path) end - describe 'ネットワークエラーのハンドリング' do - context 'safe_open がネットワークエラーで例外を投げる場合' do - before do - ENV['NEWS_RSS_PATH'] = 'https://example.com/feed.rss' - allow_any_instance_of(Object).to receive(:safe_open).and_raise(Net::OpenTimeout, '接続タイムアウト') - end - - it 'エラーをログに記録し、処理を継続する' do - expect(logger_mock).to receive(:warn).with(/⚠️ Failed to fetch .+: 接続タイムアウト/) - expect { fetch_task.invoke }.not_to raise_error - - # 空の news.yml が作成される - expect(File.exist?(yaml_path)).to be true - yaml_content = YAML.safe_load(File.read(yaml_path), permitted_classes: [Time]) - expect(yaml_content['news']).to eq([]) - end - end - - context 'HTTPエラーレスポンスの場合' do - before do - ENV['NEWS_RSS_PATH'] = 'https://example.com/feed.rss' - allow_any_instance_of(Object).to receive(:safe_open).and_raise(Net::HTTPServerException, '500 Internal Server Error') - end - - it 'エラーをログに記録し、処理を継続する' do - expect(logger_mock).to receive(:warn).with(/⚠️ Failed to fetch .+: 500 Internal Server Error/) - expect { fetch_task.invoke }.not_to raise_error - end - end - - context '不正なURLの場合' do + describe 'ネットワーク・RSSエラー時の挙動' do + context 'ネットワークエラーの場合' do before do - ENV['NEWS_RSS_PATH'] = 'https://example.com/feed.rss' - allow_any_instance_of(Object).to receive(:safe_open).and_raise('不正なURLです: https://example.com/feed.rss') + ENV['NEWS_RSS_PATH'] = 'https://invalid-url.example.com/rss' + allow(self).to receive(:safe_open).and_raise(Net::OpenTimeout, '接続タイムアウト') end - it 'エラーをログに記録し、処理を継続する' do - expect(logger_mock).to receive(:warn).with(/⚠️ Failed to fetch .+: 不正なURLです/) + it 'warnログを出し、空のnews.ymlを生成する' do + expect(logger_mock).to receive(:warn).with(/⚠️ Failed to fetch .+/) expect { fetch_task.invoke }.not_to raise_error + yaml = YAML.safe_load(File.read(yaml_path), permitted_classes: [Time]) + expect(yaml['news']).to eq([]) end end - end - describe '不正なRSSのハンドリング' do - context 'RSS::Parser.parse が失敗する場合' do + context 'RSS::Parser.parseが失敗する場合' do before do ENV['NEWS_RSS_PATH'] = 'https://example.com/feed.rss' - - # safe_open は成功するが、不正なXMLを返す - allow_any_instance_of(Object).to receive(:safe_open).and_return('not valid rss') + allow(self).to receive(:safe_open).and_return('not valid rss') end - it 'エラーをログに記録し、処理を継続する' do - expect(logger_mock).to receive(:warn).with(/⚠️ Failed to fetch .+: /) + it 'warnログを出し、空のnews.ymlを生成する' do + expect(logger_mock).to receive(:warn).with(/⚠️ Failed to fetch .+/) expect { fetch_task.invoke }.not_to raise_error - - # 空の news.yml が作成される - expect(File.exist?(yaml_path)).to be true - yaml_content = YAML.safe_load(File.read(yaml_path), permitted_classes: [Time]) - expect(yaml_content['news']).to eq([]) + yaml = YAML.safe_load(File.read(yaml_path), permitted_classes: [Time]) + expect(yaml['news']).to eq([]) end end context '空のRSSフィードの場合' do before do ENV['NEWS_RSS_PATH'] = 'https://example.com/feed.rss' - - # 有効だが空のRSSフィード empty_rss = <<~RSS @@ -105,160 +72,41 @@ RSS - - allow_any_instance_of(Object).to receive(:safe_open).and_return(empty_rss) + allow(self).to receive(:safe_open).and_return(empty_rss) end - it '空の配列として処理し、エラーにならない' do - expect { fetch_task.invoke }.not_to raise_error - - yaml_content = YAML.safe_load(File.read(yaml_path), permitted_classes: [Time]) - expect(yaml_content['news']).to eq([]) - end - end - - context 'RSSアイテムに必須フィールドが欠けている場合' do - before do - ENV['NEWS_RSS_PATH'] = 'https://example.com/feed.rss' - - # linkやpubDateが欠けているRSS - invalid_rss = <<~RSS - - - - Invalid Feed - Invalid RSS Feed - https://example.com - - タイトルのみの記事 - - - - - RSS - - allow_any_instance_of(Object).to receive(:safe_open).and_return(invalid_rss) - end - - it 'エラーをログに記録し、処理を継続する' do - expect(logger_mock).to receive(:warn).with(/⚠️ Failed to fetch .+/) + it '空配列でnews.ymlを生成する' do expect { fetch_task.invoke }.not_to raise_error + yaml = YAML.safe_load(File.read(yaml_path), permitted_classes: [Time]) + expect(yaml['news']).to eq([]) end end end describe '破損したYAMLファイルのハンドリング' do - context '既存のYAMLファイルが破損している場合' do + context '既存のYAMLが破損している場合' do before do ENV['NEWS_RSS_PATH'] = Rails.root.join('spec', 'fixtures', 'sample_news.rss').to_s - - # 破損したYAMLファイルを作成 File.write(yaml_path, "invalid yaml content:\n - broken\n indentation:\n - here") end it 'YAML読み込みエラーが発生し、タスクが失敗する' do - # YAML.safe_load のエラーは rescue されないため、タスク全体が失敗する expect { fetch_task.invoke }.to raise_error(Psych::SyntaxError) end end - context '既存のYAMLファイルが不正な構造の場合' do + context '既存のYAMLが不正な構造の場合' do before do ENV['NEWS_RSS_PATH'] = Rails.root.join('spec', 'fixtures', 'sample_news.rss').to_s - - # 不正な構造のYAMLファイル(newsキーがない) File.write(yaml_path, { 'invalid_key' => [{ 'id' => 1 }] }.to_yaml) end - it '空の配列として扱い、処理を継続する' do - expect { fetch_task.invoke }.not_to raise_error - - # 新しいデータで上書きされる - yaml_content = YAML.safe_load(File.read(yaml_path), permitted_classes: [Time]) - expect(yaml_content['news']).to be_an(Array) - expect(yaml_content['news'].size).to be > 0 - end - end - - context '許可されていないクラスを含むYAMLファイルの場合' do - before do - ENV['NEWS_RSS_PATH'] = Rails.root.join('spec', 'fixtures', 'sample_news.rss').to_s - - # DateTimeオブジェクトを含むYAML(Timeのみ許可されている) - yaml_content = { - 'news' => [ - { - 'id' => 1, - 'url' => 'https://example.com/test', - 'title' => 'テスト', - 'published_at' => DateTime.now - } - ] - } - - # 強制的にDateTimeオブジェクトを含むYAMLを作成 - File.write(yaml_path, yaml_content.to_yaml.gsub('!ruby/object:DateTime', '!ruby/object:DateTime')) - end - - it 'YAML読み込みエラーが発生し、タスクが失敗する' do - expect { fetch_task.invoke }.to raise_error(Psych::DisallowedClass) - end - end - end - - describe '複数のエラーが同時に発生する場合' do - context '複数のRSSフィードで異なるエラーが発生する場合' do - before do - # 複数のフィードURLを環境変数経由では設定できないため、 - # デフォルトの動作をオーバーライドする - allow(Rails.env).to receive(:test?).and_return(false) - allow(Rails.env).to receive(:staging?).and_return(false) - ENV.delete('NEWS_RSS_PATH') - - # 最初のフィードはネットワークエラー - allow_any_instance_of(Object).to receive(:safe_open) - .with('https://news.coderdojo.jp/feed/') - .and_raise(Net::OpenTimeout, 'タイムアウト') - end - - it '各エラーをログに記録し、処理を継続する' do - expect(logger_mock).to receive(:warn).with(/⚠️ Failed to fetch .+: タイムアウト/) - expect { fetch_task.invoke }.not_to raise_error - - # 空の news.yml が作成される - expect(File.exist?(yaml_path)).to be true - yaml_content = YAML.safe_load(File.read(yaml_path), permitted_classes: [Time]) - expect(yaml_content['news']).to eq([]) - end - end - end - - describe 'エラーリカバリー' do - context 'ネットワークエラー後に再実行した場合' do - before do - ENV['NEWS_RSS_PATH'] = Rails.root.join('spec', 'fixtures', 'sample_news.rss').to_s - end - - it '正常に処理される' do - # 最初はネットワークエラー - allow_any_instance_of(Object).to receive(:safe_open).and_raise(Net::OpenTimeout, 'タイムアウト') - expect { fetch_task.invoke }.not_to raise_error - - # エラー時は空のYAMLが作成される - yaml_content = YAML.safe_load(File.read(yaml_path), permitted_classes: [Time]) - expect(yaml_content['news']).to eq([]) - - # safe_openのモックを解除して正常動作に戻す - allow_any_instance_of(Object).to receive(:safe_open).and_call_original - - # タスクを再実行可能にする - fetch_task.reenable - - # 再実行すると正常に処理される + it '空配列として扱い、正常に上書きされる' do expect { fetch_task.invoke }.not_to raise_error - yaml_content = YAML.safe_load(File.read(yaml_path), permitted_classes: [Time]) - expect(yaml_content['news'].size).to be > 0 + yaml = YAML.safe_load(File.read(yaml_path), permitted_classes: [Time]) + expect(yaml['news']).to be_an(Array) + expect(yaml['news'].size).to be > 0 end end end -end \ No newline at end of file +end From 1850e1f20a65ac035d7c3d07f5503d804d0210e4 Mon Sep 17 00:00:00 2001 From: nacchan Date: Wed, 30 Jul 2025 12:12:03 +0900 Subject: [PATCH 06/12] =?UTF-8?q?=E6=96=B9=E9=87=9D=E5=A4=89=E6=9B=B4?= =?UTF-8?q?=E3=81=AB=E3=82=88=E3=82=8A=E4=B8=8D=E8=A6=81=E3=81=A8=E3=81=AA?= =?UTF-8?q?=E3=81=A3=E3=81=9Fnews=E3=82=BF=E3=82=B9=E3=82=AF=E9=96=A2?= =?UTF-8?q?=E9=80=A3=E3=81=AE=E3=83=86=E3=82=B9=E3=83=88=E3=82=92=E5=89=8A?= =?UTF-8?q?=E9=99=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- spec/tasks/news_fetch_error_handling_spec.rb | 112 ------------------- spec/tasks/news_rake_spec.rb | 86 -------------- 2 files changed, 198 deletions(-) delete mode 100644 spec/tasks/news_fetch_error_handling_spec.rb delete mode 100644 spec/tasks/news_rake_spec.rb diff --git a/spec/tasks/news_fetch_error_handling_spec.rb b/spec/tasks/news_fetch_error_handling_spec.rb deleted file mode 100644 index 7bf71d555..000000000 --- a/spec/tasks/news_fetch_error_handling_spec.rb +++ /dev/null @@ -1,112 +0,0 @@ -require 'rails_helper' -require 'rake' -require 'yaml' -require 'net/http' - -RSpec.describe 'news:fetch エラーハンドリング', type: :task do - before { Rails.application.load_tasks } - before { allow(Rails.env).to receive(:test?).and_return(true) } - - let(:yaml_path) { Rails.root.join('tmp', 'error_test_news.yml') } - let(:fetch_task) { Rake::Task['news:fetch'] } - let(:logger_mock) { instance_double(ActiveSupport::BroadcastLogger) } - - around do |example| - File.delete(yaml_path) if File.exist?(yaml_path) - example.run - File.delete(yaml_path) if File.exist?(yaml_path) - end - - before do - ENV['NEWS_YAML_PATH'] = yaml_path.to_s - fetch_task.reenable - allow(ActiveSupport::BroadcastLogger).to receive(:new).and_return(logger_mock) - allow(logger_mock).to receive(:info) - allow(logger_mock).to receive(:warn) - end - - after do - ENV.delete('NEWS_YAML_PATH') - ENV.delete('NEWS_RSS_PATH') - end - - describe 'ネットワーク・RSSエラー時の挙動' do - context 'ネットワークエラーの場合' do - before do - ENV['NEWS_RSS_PATH'] = 'https://invalid-url.example.com/rss' - allow(self).to receive(:safe_open).and_raise(Net::OpenTimeout, '接続タイムアウト') - end - - it 'warnログを出し、空のnews.ymlを生成する' do - expect(logger_mock).to receive(:warn).with(/⚠️ Failed to fetch .+/) - expect { fetch_task.invoke }.not_to raise_error - yaml = YAML.safe_load(File.read(yaml_path), permitted_classes: [Time]) - expect(yaml['news']).to eq([]) - end - end - - context 'RSS::Parser.parseが失敗する場合' do - before do - ENV['NEWS_RSS_PATH'] = 'https://example.com/feed.rss' - allow(self).to receive(:safe_open).and_return('not valid rss') - end - - it 'warnログを出し、空のnews.ymlを生成する' do - expect(logger_mock).to receive(:warn).with(/⚠️ Failed to fetch .+/) - expect { fetch_task.invoke }.not_to raise_error - yaml = YAML.safe_load(File.read(yaml_path), permitted_classes: [Time]) - expect(yaml['news']).to eq([]) - end - end - - context '空のRSSフィードの場合' do - before do - ENV['NEWS_RSS_PATH'] = 'https://example.com/feed.rss' - empty_rss = <<~RSS - - - - Empty Feed - Empty RSS Feed - https://example.com - - - RSS - allow(self).to receive(:safe_open).and_return(empty_rss) - end - - it '空配列でnews.ymlを生成する' do - expect { fetch_task.invoke }.not_to raise_error - yaml = YAML.safe_load(File.read(yaml_path), permitted_classes: [Time]) - expect(yaml['news']).to eq([]) - end - end - end - - describe '破損したYAMLファイルのハンドリング' do - context '既存のYAMLが破損している場合' do - before do - ENV['NEWS_RSS_PATH'] = Rails.root.join('spec', 'fixtures', 'sample_news.rss').to_s - File.write(yaml_path, "invalid yaml content:\n - broken\n indentation:\n - here") - end - - it 'YAML読み込みエラーが発生し、タスクが失敗する' do - expect { fetch_task.invoke }.to raise_error(Psych::SyntaxError) - end - end - - context '既存のYAMLが不正な構造の場合' do - before do - ENV['NEWS_RSS_PATH'] = Rails.root.join('spec', 'fixtures', 'sample_news.rss').to_s - File.write(yaml_path, { 'invalid_key' => [{ 'id' => 1 }] }.to_yaml) - end - - it '空配列として扱い、正常に上書きされる' do - expect { fetch_task.invoke }.not_to raise_error - yaml = YAML.safe_load(File.read(yaml_path), permitted_classes: [Time]) - expect(yaml['news']).to be_an(Array) - expect(yaml['news'].size).to be > 0 - end - end - end -end diff --git a/spec/tasks/news_rake_spec.rb b/spec/tasks/news_rake_spec.rb deleted file mode 100644 index 2bd586021..000000000 --- a/spec/tasks/news_rake_spec.rb +++ /dev/null @@ -1,86 +0,0 @@ -require 'rails_helper' -require 'rake' -require 'yaml' - -RSpec.describe 'news Rakeタスク', type: :task do - before { Rails.application.load_tasks } - before { allow(Rails.env).to receive(:test?).and_return(true) } - - # テスト用に tmp/news.yml を使う - let(:yaml_path) { Rails.root.join('tmp', 'news.yml') } - let(:fetch_task) { Rake::Task['news:fetch'] } - let(:import_task) { Rake::Task['news:import_from_yaml'] } - let(:yaml_content) { YAML.safe_load(File.read(yaml_path), permitted_classes: [Time]) } - - around do |example| - # テスト前後に一度だけ tmp/news.yml をクリア - File.delete(yaml_path) if File.exist?(yaml_path) - example.run - File.delete(yaml_path) if File.exist?(yaml_path) - end - - describe 'news:fetch タスク' do - before do - ENV['NEWS_YAML_PATH'] = yaml_path.to_s - ENV['NEWS_RSS_PATH'] = Rails.root.join('spec', 'fixtures', 'sample_news.rss').to_s - fetch_task.reenable - end - - after do - ENV.delete('NEWS_YAML_PATH') - ENV.delete('NEWS_RSS_PATH') - end - - it 'サンプルRSSからニュースを取得し YAML に書き込む' do - expect { fetch_task.invoke }.not_to raise_error - expect(File.exist?(yaml_path)).to be true - expect(yaml_content['news']).to be_an(Array) - expect(yaml_content['news'].size).to eq(3) - end - - it 'ID が 1 から連番で付与される' do - fetch_task.invoke - ids = yaml_content['news'].map { |item| item['id'] } - expect(ids).to eq([1, 2, 3]) - end - - it '公開日時で降順ソートされる' do - fetch_task.invoke - dates = yaml_content['news'].map { |item| Time.parse(item['published_at']) } - expect(dates).to eq(dates.sort.reverse) - end - end - - describe 'news:import_from_yaml タスク' do - let(:news_data) do - { - 'news' => [ - { 'id' => 1, 'url' => 'https://example.com/test1', 'title' => 'テスト記事1', 'published_at' => '2025-01-01T10:00:00Z' }, - { 'id' => 2, 'url' => 'https://example.com/test2', 'title' => 'テスト記事2', 'published_at' => '2025-01-02T10:00:00Z' } - ] - } - end - - before do - ENV['NEWS_YAML_PATH'] = yaml_path.to_s - File.write(yaml_path, news_data.to_yaml) - import_task.reenable - end - - after do - ENV.delete('NEWS_YAML_PATH') - end - - it 'YAML ファイルから News レコードを新規作成する' do - expect { import_task.invoke }.to change(News, :count).by(2) - expect(News.find_by(url: news_data['news'][0]['url']).title).to eq('テスト記事1') - expect(News.find_by(url: news_data['news'][1]['url']).title).to eq('テスト記事2') - end - - it '既存レコードがあれば属性を更新する' do - create(:news, url: news_data['news'][0]['url'], title: '古いタイトル') - expect { import_task.invoke }.to change(News, :count).by(1) - expect(News.find_by(url: news_data['news'][0]['url']).title).to eq('テスト記事1') - end - end -end From d4e01503909f1f9c680ac8d07e7fce22c1a08c0f Mon Sep 17 00:00:00 2001 From: nacchan Date: Mon, 4 Aug 2025 12:43:45 +0900 Subject: [PATCH 07/12] =?UTF-8?q?chore:=20=E4=BB=8A=E5=9B=9E=E3=81=AEPR?= =?UTF-8?q?=E3=81=A8=E9=96=A2=E4=BF=82=E3=81=AA=E3=81=84=E5=A4=89=E6=9B=B4?= =?UTF-8?q?=E3=82=92=E5=89=8A=E9=99=A4=EF=BC=88#1724=E3=81=AE=E9=87=8D?= =?UTF-8?q?=E8=A4=87=E5=88=86=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/tasks/fetch_news.rake | 30 ++++++++++++++++-------------- lib/tasks/import_news.rake | 5 ++--- 2 files changed, 18 insertions(+), 17 deletions(-) diff --git a/lib/tasks/fetch_news.rake b/lib/tasks/fetch_news.rake index 00de49275..415d5df25 100644 --- a/lib/tasks/fetch_news.rake +++ b/lib/tasks/fetch_news.rake @@ -7,8 +7,8 @@ require 'active_support/broadcast_logger' def safe_open(url) uri = URI.parse(url) - return File.read(url) if uri.scheme.nil? || uri.scheme == 'file' raise "不正なURLです: #{url}" unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS) + Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https') do |http| request = Net::HTTP::Get.new(uri) response = http.request(request) @@ -26,19 +26,25 @@ namespace :news do logger.info('==== START news:fetch ====') - # YAML出力先を環境変数で上書きできるようにする - yaml_path = ENV['NEWS_YAML_PATH'] ? Pathname.new(ENV['NEWS_YAML_PATH']) : Rails.root.join('db', 'news.yml') - feed_urls = ENV['NEWS_RSS_PATH'] ? [ENV['NEWS_RSS_PATH']] : - (Rails.env.test? || Rails.env.staging? ? - [Rails.root.join('spec', 'fixtures', 'sample_news.rss').to_s] : - ['https://news.coderdojo.jp/feed/']) - + # 既存の news.yml を読み込み + yaml_path = Rails.root.join('db', 'news.yml') existing_news = if File.exist?(yaml_path) YAML.safe_load(File.read(yaml_path), permitted_classes: [Time], aliases: true)['news'] || [] else [] end + # テスト/ステージング環境ではサンプルファイル、本番は実サイトのフィード + feed_urls = if Rails.env.test? || Rails.env.staging? + [Rails.root.join('spec', 'fixtures', 'sample_news.rss').to_s] + else + [ + 'https://news.coderdojo.jp/feed/' + # 必要に応じて他 Dojo の RSS もここに追加可能 + # 'https://coderdojotokyo.org/feed', + ] + end + # RSS 取得&パース new_items = feed_urls.flat_map do |url| logger.info("Fetching RSS → #{url}") @@ -101,11 +107,7 @@ namespace :news do Time.parse(item['published_at']) }.reverse - sorted_items.each_with_index do |item, index| - item['id'] = index + 1 - end - - File.open(yaml_path, 'w') do |f| + File.open('db/news.yml', 'w') do |f| formatted_items = sorted_items.map do |item| { 'id' => item['id'], @@ -118,7 +120,7 @@ namespace :news do f.write({ 'news' => formatted_items }.to_yaml) end - logger.info("✅ Wrote #{sorted_items.size} items to #{yaml_path} (#{truly_new_items_sorted.size} new, #{updated_items.size} updated)") + logger.info("✅ Wrote #{sorted_items.size} items to db/news.yml (#{truly_new_items_sorted.size} new, #{updated_items.size} updated)") logger.info('==== END news:fetch ====') end end diff --git a/lib/tasks/import_news.rake b/lib/tasks/import_news.rake index ba73110df..bebe76611 100644 --- a/lib/tasks/import_news.rake +++ b/lib/tasks/import_news.rake @@ -1,10 +1,9 @@ require 'yaml' namespace :news do - desc 'db/news.yml (またはENV指定のYAML)を読み込んで News テーブルを upsert する' + desc 'db/news.yml を読み込んで News テーブルを upsert する' task import_from_yaml: :environment do - # ENVで上書き可能にする(なければデフォルト db/news.yml) - yaml_path = ENV['NEWS_YAML_PATH'] ? Pathname.new(ENV['NEWS_YAML_PATH']) : Rails.root.join('db', 'news.yml') + yaml_path = Rails.root.join('db', 'news.yml') raw = YAML.safe_load(File.read(yaml_path), permitted_classes: [Time], aliases: true) # entries を計算 From 7b0b652b45a16c2376d2c973fdc6748c088b70be Mon Sep 17 00:00:00 2001 From: nacchan Date: Mon, 4 Aug 2025 14:00:57 +0900 Subject: [PATCH 08/12] =?UTF-8?q?chore:=20=E4=B8=8D=E8=A6=81=E3=81=AA?= =?UTF-8?q?=E3=83=95=E3=82=A1=E3=82=AF=E3=83=88=E3=83=AA=E3=83=BC=E3=83=86?= =?UTF-8?q?=E3=82=B9=E3=83=88=E3=82=92=E5=89=8A=E9=99=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- spec/models/news_spec.rb | 4 ---- 1 file changed, 4 deletions(-) diff --git a/spec/models/news_spec.rb b/spec/models/news_spec.rb index 45a01f29a..8fb320fb6 100644 --- a/spec/models/news_spec.rb +++ b/spec/models/news_spec.rb @@ -4,10 +4,6 @@ describe 'バリデーション' do let(:news) { build(:news) } - it '有効なファクトリーを持つ' do - expect(news).to be_valid - end - describe 'title' do it 'タイトルが空の場合は無効になる' do news.title = nil From 76202d8da820b9a1a4fed77dbb78dbe64e5e0f21 Mon Sep 17 00:00:00 2001 From: nacchan Date: Mon, 4 Aug 2025 14:13:31 +0900 Subject: [PATCH 09/12] =?UTF-8?q?test:=20title=E3=83=90=E3=83=AA=E3=83=87?= =?UTF-8?q?=E3=83=BC=E3=82=B7=E3=83=A7=E3=83=B3=E3=81=AE=E6=9C=89=E5=8A=B9?= =?UTF-8?q?=E3=82=B1=E3=83=BC=E3=82=B9=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- spec/models/news_spec.rb | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/spec/models/news_spec.rb b/spec/models/news_spec.rb index 8fb320fb6..6920884f2 100644 --- a/spec/models/news_spec.rb +++ b/spec/models/news_spec.rb @@ -6,9 +6,14 @@ describe 'title' do it 'タイトルが空の場合は無効になる' do - news.title = nil - expect(news).not_to be_valid - expect(news.errors[:title]).not_to be_empty + news.title = nil + expect(news).not_to be_valid + expect(news.errors[:title]).not_to be_empty + end + + it 'タイトルが正しく設定されている場合は有効になる' do + news.title = '有効なタイトル' + expect(news).to be_valid end end From 270809103ebabb83b523586a159c18d98c0cba28 Mon Sep 17 00:00:00 2001 From: nacchan Date: Mon, 4 Aug 2025 14:27:21 +0900 Subject: [PATCH 10/12] =?UTF-8?q?test:=20published=5Fat=E3=83=90=E3=83=AA?= =?UTF-8?q?=E3=83=87=E3=83=BC=E3=82=B7=E3=83=A7=E3=83=B3=E3=81=AE=E6=9C=89?= =?UTF-8?q?=E5=8A=B9=E3=82=B1=E3=83=BC=E3=82=B9=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- spec/models/news_spec.rb | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/spec/models/news_spec.rb b/spec/models/news_spec.rb index 6920884f2..cd2bdfbfb 100644 --- a/spec/models/news_spec.rb +++ b/spec/models/news_spec.rb @@ -48,9 +48,14 @@ describe 'published_at' do it '公開日時が空の場合は無効になる' do - news.published_at = nil - expect(news).not_to be_valid - expect(news.errors[:published_at]).not_to be_empty + news.published_at = nil + expect(news).not_to be_valid + expect(news.errors[:published_at]).not_to be_empty + end + + it '公開日時が正しく設定されている場合は有効になる' do + news.published_at = Time.current + expect(news).to be_valid end end end From 3cb6d0d680d9fcc96667576caa2ec9939877d4b7 Mon Sep 17 00:00:00 2001 From: nacchan Date: Mon, 4 Aug 2025 14:46:23 +0900 Subject: [PATCH 11/12] =?UTF-8?q?refactor:=20url=E3=83=90=E3=83=AA?= =?UTF-8?q?=E3=83=87=E3=83=BC=E3=82=B7=E3=83=A7=E3=83=B3=E3=83=86=E3=82=B9?= =?UTF-8?q?=E3=83=88=E3=82=92context=E3=81=A7=E6=95=B4=E7=90=86=E3=81=97?= =?UTF-8?q?=E3=80=81=E8=AA=AC=E6=98=8E=E6=96=87=E3=82=92=E6=94=B9=E5=96=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- spec/models/news_spec.rb | 46 +++++++++++++++++++++++----------------- 1 file changed, 26 insertions(+), 20 deletions(-) diff --git a/spec/models/news_spec.rb b/spec/models/news_spec.rb index cd2bdfbfb..139913875 100644 --- a/spec/models/news_spec.rb +++ b/spec/models/news_spec.rb @@ -18,31 +18,37 @@ end describe 'url' do - it 'URL が空の場合は無効になる' do - news.url = nil - expect(news).not_to be_valid - expect(news.errors[:url]).not_to be_empty - end + context '無効な場合' do + it 'URL が空の場合は無効になる' do + news.url = nil + expect(news).not_to be_valid + expect(news.errors[:url]).not_to be_empty + end - it 'URL が重複している場合は無効になる' do - create(:news, url: 'https://example.com/test') - duplicate_news = build(:news, url: 'https://example.com/test') - expect(duplicate_news).not_to be_valid - expect(duplicate_news.errors[:url]).not_to be_empty - end + it 'URL が重複している場合は無効になる' do + create(:news, url: 'https://example.com/test') + duplicate_news = build(:news, url: 'https://example.com/test') + expect(duplicate_news).not_to be_valid + expect(duplicate_news.errors[:url]).not_to be_empty + end - it 'URL形式であること' do - news.url = 'invalid-url' - expect(news).not_to be_valid - expect(news.errors[:url]).not_to be_empty + it 'URL形式でない場合は無効になる' do + news.url = 'invalid-url' + expect(news).not_to be_valid + expect(news.errors[:url]).not_to be_empty + end end - it 'HTTPSとHTTPを許可する' do - news.url = 'https://example.com' - expect(news).to be_valid + context '有効な場合' do + it 'HTTPSを許可する' do + news.url = 'https://example.com' + expect(news).to be_valid + end - news.url = 'http://example.com' - expect(news).to be_valid + it 'HTTPを許可する' do + news.url = 'http://example.com' + expect(news).to be_valid + end end end From 2305a9ddca1bbbd8b7ed442d271b3a2e79f32b97 Mon Sep 17 00:00:00 2001 From: nacchan Date: Mon, 4 Aug 2025 16:10:05 +0900 Subject: [PATCH 12/12] =?UTF-8?q?style:=20title=E3=83=BBpublished=5Fat?= =?UTF-8?q?=E3=82=BB=E3=82=AF=E3=82=B7=E3=83=A7=E3=83=B3=E3=81=AE=E3=82=A4?= =?UTF-8?q?=E3=83=B3=E3=83=87=E3=83=B3=E3=83=88=E3=82=92=E8=AA=BF=E6=95=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- spec/models/news_spec.rb | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/spec/models/news_spec.rb b/spec/models/news_spec.rb index 139913875..3beba3351 100644 --- a/spec/models/news_spec.rb +++ b/spec/models/news_spec.rb @@ -6,14 +6,14 @@ describe 'title' do it 'タイトルが空の場合は無効になる' do - news.title = nil - expect(news).not_to be_valid - expect(news.errors[:title]).not_to be_empty + news.title = nil + expect(news).not_to be_valid + expect(news.errors[:title]).not_to be_empty end it 'タイトルが正しく設定されている場合は有効になる' do - news.title = '有効なタイトル' - expect(news).to be_valid + news.title = '有効なタイトル' + expect(news).to be_valid end end @@ -54,14 +54,14 @@ describe 'published_at' do it '公開日時が空の場合は無効になる' do - news.published_at = nil - expect(news).not_to be_valid - expect(news.errors[:published_at]).not_to be_empty + news.published_at = nil + expect(news).not_to be_valid + expect(news.errors[:published_at]).not_to be_empty end it '公開日時が正しく設定されている場合は有効になる' do - news.published_at = Time.current - expect(news).to be_valid + news.published_at = Time.current + expect(news).to be_valid end end end