Skip to content

[WIP] News specの強化(追加ケースの実装) #1719

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 5 commits into
base: main
Choose a base branch
from

Conversation

nacchan99
Copy link
Contributor

@nacchan99 nacchan99 commented Jul 24, 2025

Fixes #1718 (cf. #1704 ,cf. #1577

背景

News モデルに関するテストがまだ不足しているため、 CI の安定化とコード品質向上のために追加テストを実装します。

やること

  • モデルテスト強化 (spec/models/news_spec.rb)
  • Rakeタスクテスト追加 (spec/tasks/news_rake_spec.rb)
  • エラーハンドリングテスト実装 (spec/tasks/news_fetch_error_handling_spec.rb)
  • セキュリティテスト 追加(spec/security/news_security_spec.rb)

参考

#1704 (comment)

- news:fetchタスクでfileスキーム/ローカルパスをsafe_openで許可(テスト用RSSの読み込みに対応)
- YAML書き込み・ログ出力のパスをENV['NEWS_YAML_PATH']で上書き可能なyaml_pathに統一
- ソート後に全アイテムへ1からの連番IDを再付与
- news:import_from_yamlタスクもENV['NEWS_YAML_PATH']に対応
@nacchan99
Copy link
Contributor Author

@claude
Rakeタスク news:fetch のエラーハンドリング用RSpecを書きたいです。

想定するケースは以下の3つです。

1,ネットワークエラー(safe_open が例外を投げる)
2,不正なRSSで RSS::Parser.parse が失敗
3,破損したYAMLファイルで YAML.safe_load が失敗

これらを網羅的にテストするための、spec/tasks/news_fetch_error_handling_spec.rb のサンプルコードを提案してもらえますか?

Copy link

claude bot commented Jul 25, 2025

Claude finished @nacchan99's task —— View job


タスクリスト

  • 現在の news:fetch Rake タスクの実装を確認
  • 既存のテスト構造を理解
  • エラーハンドリングのテストコードを作成
    • ネットワークエラーのテスト
    • 不正なRSSのテスト
    • 破損したYAMLファイルのテスト
  • 最終的なコードと解説を提供

完成したエラーハンドリングテスト

spec/tasks/news_fetch_error_handling_spec.rb を作成し、PRブランチにコミット・プッシュしました。

テストコードの全文
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('<invalid>not valid rss</invalid>')
      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
          <?xml version="1.0" encoding="UTF-8"?>
          <rss version="2.0">
            <channel>
              <title>Empty Feed</title>
              <description>Empty RSS Feed</description>
              <link>https://example.com</link>
            </channel>
          </rss>
        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
          <?xml version="1.0" encoding="UTF-8"?>
          <rss version="2.0">
            <channel>
              <title>Invalid Feed</title>
              <description>Invalid RSS Feed</description>
              <link>https://example.com</link>
              <item>
                <title>タイトルのみの記事</title>
                <!-- link と pubDate が欠けている -->
              </item>
            </channel>
          </rss>
        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
テストケースの詳細説明

1. ネットワークエラーのハンドリング

  • 接続タイムアウト: Net::OpenTimeout 例外をシミュレート
  • HTTPサーバーエラー: 500エラーなどのHTTPエラーレスポンス
  • 不正なURL: URLバリデーションで弾かれるケース

これらのエラーは rescue ブロックでキャッチされ、警告ログを出力して処理を継続します。

2. 不正なRSSのハンドリング

  • 無効なXML: パースできないXMLデータ
  • 空のRSSフィード: アイテムが含まれていないフィード
  • 必須フィールドの欠落: linkpubDate が欠けているアイテム

RSSパースエラーも同様に rescue でキャッチされ、空の配列として処理されます。

3. 破損したYAMLファイルのハンドリング

  • 構文エラー: インデントが壊れたYAML → Psych::SyntaxError で失敗
  • 不正な構造: news キーがない → 空の配列として扱い継続
  • 許可されていないクラス: DateTime など → Psych::DisallowedClass で失敗

YAMLの構文エラーは rescue されないため、タスク全体が失敗します。これは意図的な仕様です。

4. 追加のテストケース

  • 複数エラーの同時発生: 複数のフィードで異なるエラーが発生するケース
  • エラーリカバリー: エラー後の再実行で正常動作を確認
実装上の注意点

モックの使用

  • safe_open メソッドをモック化してネットワークエラーをシミュレート
  • ActiveSupport::BroadcastLogger をモック化してログ出力を検証
  • 環境変数でテスト用のパスを設定

エラーハンドリングの現状

現在の実装では:

  • RSS取得・パースエラーは rescue でキャッチされ、処理が継続される
  • YAML読み込みエラーは rescue されず、タスクが失敗する

この違いは意図的なもので、YAMLファイルの破損は重大な問題として扱われています。

改善提案

もしYAMLエラーも graceful に処理したい場合は、以下のような修正が考えられます:

existing_news = begin
  if File.exist?(yaml_path)
    YAML.safe_load(File.read(yaml_path), permitted_classes: [Time], aliases: true)['news'] || []
  else
    []
  end
rescue Psych::SyntaxError, Psych::DisallowedClass => e
  logger.warn("⚠️ Failed to load existing YAML: #{e.message}")
  []
end

要望された3つのエラーケースを網羅的にカバーするテストファイルを作成し、PRにプッシュしました。モックを使用してエラー状況を再現し、適切にエラーハンドリングされることを確認できるようになっています。
|

claude bot and others added 2 commits July 25, 2025 00:31
- ネットワークエラー(接続タイムアウト、HTTPエラー、不正なURL)のテスト
- 不正なRSS(無効なXML、空のフィード、必須フィールドの欠落)のテスト
- 破損したYAMLファイル(構文エラー、不正な構造、許可されていないクラス)のテスト
- 複数エラーの同時発生とエラーリカバリーのテストも含む

Co-authored-by: nacchan99 <nacchan99@users.noreply.github.com>
- ネットワークエラーやRSS解析エラー時の挙動を確認
- 破損・不正なYAMLファイルのケースを追加
- ロガーをモック化してwarnログの出力を検証
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

news セクションの自動生成に関するテストを追加する
1 participant
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy