1+ require 'rails_helper'
2+ require 'rake'
3+ require 'yaml'
4+ require 'net/http'
5+
6+ RSpec . describe 'news:fetch エラーハンドリング' , type : :task do
7+ before { Rails . application . load_tasks }
8+ before { allow ( Rails . env ) . to receive ( :test? ) . and_return ( true ) }
9+
10+ let ( :yaml_path ) { Rails . root . join ( 'tmp' , 'error_test_news.yml' ) }
11+ let ( :fetch_task ) { Rake ::Task [ 'news:fetch' ] }
12+ let ( :logger_mock ) { instance_double ( ActiveSupport ::BroadcastLogger ) }
13+
14+ before do
15+ ENV [ 'NEWS_YAML_PATH' ] = yaml_path . to_s
16+ fetch_task . reenable
17+
18+ # ロガーのモック設定
19+ allow ( ActiveSupport ::BroadcastLogger ) . to receive ( :new ) . and_return ( logger_mock )
20+ allow ( logger_mock ) . to receive ( :info )
21+ allow ( logger_mock ) . to receive ( :warn )
22+ end
23+
24+ after do
25+ ENV . delete ( 'NEWS_YAML_PATH' )
26+ ENV . delete ( 'NEWS_RSS_PATH' )
27+ File . delete ( yaml_path ) if File . exist? ( yaml_path )
28+ end
29+
30+ describe 'ネットワークエラーのハンドリング' do
31+ context 'safe_open がネットワークエラーで例外を投げる場合' do
32+ before do
33+ ENV [ 'NEWS_RSS_PATH' ] = 'https://example.com/feed.rss'
34+ allow_any_instance_of ( Object ) . to receive ( :safe_open ) . and_raise ( Net ::OpenTimeout , '接続タイムアウト' )
35+ end
36+
37+ it 'エラーをログに記録し、処理を継続する' do
38+ expect ( logger_mock ) . to receive ( :warn ) . with ( /⚠️ Failed to fetch .+: 接続タイムアウト/ )
39+ expect { fetch_task . invoke } . not_to raise_error
40+
41+ # 空の news.yml が作成される
42+ expect ( File . exist? ( yaml_path ) ) . to be true
43+ yaml_content = YAML . safe_load ( File . read ( yaml_path ) , permitted_classes : [ Time ] )
44+ expect ( yaml_content [ 'news' ] ) . to eq ( [ ] )
45+ end
46+ end
47+
48+ context 'HTTPエラーレスポンスの場合' do
49+ before do
50+ ENV [ 'NEWS_RSS_PATH' ] = 'https://example.com/feed.rss'
51+ allow_any_instance_of ( Object ) . to receive ( :safe_open ) . and_raise ( Net ::HTTPServerException , '500 Internal Server Error' )
52+ end
53+
54+ it 'エラーをログに記録し、処理を継続する' do
55+ expect ( logger_mock ) . to receive ( :warn ) . with ( /⚠️ Failed to fetch .+: 500 Internal Server Error/ )
56+ expect { fetch_task . invoke } . not_to raise_error
57+ end
58+ end
59+
60+ context '不正なURLの場合' do
61+ before do
62+ ENV [ 'NEWS_RSS_PATH' ] = 'https://example.com/feed.rss'
63+ allow_any_instance_of ( Object ) . to receive ( :safe_open ) . and_raise ( '不正なURLです: https://example.com/feed.rss' )
64+ end
65+
66+ it 'エラーをログに記録し、処理を継続する' do
67+ expect ( logger_mock ) . to receive ( :warn ) . with ( /⚠️ Failed to fetch .+: 不正なURLです/ )
68+ expect { fetch_task . invoke } . not_to raise_error
69+ end
70+ end
71+ end
72+
73+ describe '不正なRSSのハンドリング' do
74+ context 'RSS::Parser.parse が失敗する場合' do
75+ before do
76+ ENV [ 'NEWS_RSS_PATH' ] = 'https://example.com/feed.rss'
77+
78+ # safe_open は成功するが、不正なXMLを返す
79+ allow_any_instance_of ( Object ) . to receive ( :safe_open ) . and_return ( '<invalid>not valid rss</invalid>' )
80+ end
81+
82+ it 'エラーをログに記録し、処理を継続する' do
83+ expect ( logger_mock ) . to receive ( :warn ) . with ( /⚠️ Failed to fetch .+: / )
84+ expect { fetch_task . invoke } . not_to raise_error
85+
86+ # 空の news.yml が作成される
87+ expect ( File . exist? ( yaml_path ) ) . to be true
88+ yaml_content = YAML . safe_load ( File . read ( yaml_path ) , permitted_classes : [ Time ] )
89+ expect ( yaml_content [ 'news' ] ) . to eq ( [ ] )
90+ end
91+ end
92+
93+ context '空のRSSフィードの場合' do
94+ before do
95+ ENV [ 'NEWS_RSS_PATH' ] = 'https://example.com/feed.rss'
96+
97+ # 有効だが空のRSSフィード
98+ empty_rss = <<~RSS
99+ <?xml version="1.0" encoding="UTF-8"?>
100+ <rss version="2.0">
101+ <channel>
102+ <title>Empty Feed</title>
103+ <description>Empty RSS Feed</description>
104+ <link>https://example.com</link>
105+ </channel>
106+ </rss>
107+ RSS
108+
109+ allow_any_instance_of ( Object ) . to receive ( :safe_open ) . and_return ( empty_rss )
110+ end
111+
112+ it '空の配列として処理し、エラーにならない' do
113+ expect { fetch_task . invoke } . not_to raise_error
114+
115+ yaml_content = YAML . safe_load ( File . read ( yaml_path ) , permitted_classes : [ Time ] )
116+ expect ( yaml_content [ 'news' ] ) . to eq ( [ ] )
117+ end
118+ end
119+
120+ context 'RSSアイテムに必須フィールドが欠けている場合' do
121+ before do
122+ ENV [ 'NEWS_RSS_PATH' ] = 'https://example.com/feed.rss'
123+
124+ # linkやpubDateが欠けているRSS
125+ invalid_rss = <<~RSS
126+ <?xml version="1.0" encoding="UTF-8"?>
127+ <rss version="2.0">
128+ <channel>
129+ <title>Invalid Feed</title>
130+ <description>Invalid RSS Feed</description>
131+ <link>https://example.com</link>
132+ <item>
133+ <title>タイトルのみの記事</title>
134+ <!-- link と pubDate が欠けている -->
135+ </item>
136+ </channel>
137+ </rss>
138+ RSS
139+
140+ allow_any_instance_of ( Object ) . to receive ( :safe_open ) . and_return ( invalid_rss )
141+ end
142+
143+ it 'エラーをログに記録し、処理を継続する' do
144+ expect ( logger_mock ) . to receive ( :warn ) . with ( /⚠️ Failed to fetch .+/ )
145+ expect { fetch_task . invoke } . not_to raise_error
146+ end
147+ end
148+ end
149+
150+ describe '破損したYAMLファイルのハンドリング' do
151+ context '既存のYAMLファイルが破損している場合' do
152+ before do
153+ ENV [ 'NEWS_RSS_PATH' ] = Rails . root . join ( 'spec' , 'fixtures' , 'sample_news.rss' ) . to_s
154+
155+ # 破損したYAMLファイルを作成
156+ File . write ( yaml_path , "invalid yaml content:\n - broken\n indentation:\n - here" )
157+ end
158+
159+ it 'YAML読み込みエラーが発生し、タスクが失敗する' do
160+ # YAML.safe_load のエラーは rescue されないため、タスク全体が失敗する
161+ expect { fetch_task . invoke } . to raise_error ( Psych ::SyntaxError )
162+ end
163+ end
164+
165+ context '既存のYAMLファイルが不正な構造の場合' do
166+ before do
167+ ENV [ 'NEWS_RSS_PATH' ] = Rails . root . join ( 'spec' , 'fixtures' , 'sample_news.rss' ) . to_s
168+
169+ # 不正な構造のYAMLファイル(newsキーがない)
170+ File . write ( yaml_path , { 'invalid_key' => [ { 'id' => 1 } ] } . to_yaml )
171+ end
172+
173+ it '空の配列として扱い、処理を継続する' do
174+ expect { fetch_task . invoke } . not_to raise_error
175+
176+ # 新しいデータで上書きされる
177+ yaml_content = YAML . safe_load ( File . read ( yaml_path ) , permitted_classes : [ Time ] )
178+ expect ( yaml_content [ 'news' ] ) . to be_an ( Array )
179+ expect ( yaml_content [ 'news' ] . size ) . to be > 0
180+ end
181+ end
182+
183+ context '許可されていないクラスを含むYAMLファイルの場合' do
184+ before do
185+ ENV [ 'NEWS_RSS_PATH' ] = Rails . root . join ( 'spec' , 'fixtures' , 'sample_news.rss' ) . to_s
186+
187+ # DateTimeオブジェクトを含むYAML(Timeのみ許可されている)
188+ yaml_content = {
189+ 'news' => [
190+ {
191+ 'id' => 1 ,
192+ 'url' => 'https://example.com/test' ,
193+ 'title' => 'テスト' ,
194+ 'published_at' => DateTime . now
195+ }
196+ ]
197+ }
198+
199+ # 強制的にDateTimeオブジェクトを含むYAMLを作成
200+ File . write ( yaml_path , yaml_content . to_yaml . gsub ( '!ruby/object:DateTime' , '!ruby/object:DateTime' ) )
201+ end
202+
203+ it 'YAML読み込みエラーが発生し、タスクが失敗する' do
204+ expect { fetch_task . invoke } . to raise_error ( Psych ::DisallowedClass )
205+ end
206+ end
207+ end
208+
209+ describe '複数のエラーが同時に発生する場合' do
210+ context '複数のRSSフィードで異なるエラーが発生する場合' do
211+ before do
212+ # 複数のフィードURLを環境変数経由では設定できないため、
213+ # デフォルトの動作をオーバーライドする
214+ allow ( Rails . env ) . to receive ( :test? ) . and_return ( false )
215+ allow ( Rails . env ) . to receive ( :staging? ) . and_return ( false )
216+ ENV . delete ( 'NEWS_RSS_PATH' )
217+
218+ # 最初のフィードはネットワークエラー
219+ allow_any_instance_of ( Object ) . to receive ( :safe_open )
220+ . with ( 'https://news.coderdojo.jp/feed/' )
221+ . and_raise ( Net ::OpenTimeout , 'タイムアウト' )
222+ end
223+
224+ it '各エラーをログに記録し、処理を継続する' do
225+ expect ( logger_mock ) . to receive ( :warn ) . with ( /⚠️ Failed to fetch .+: タイムアウト/ )
226+ expect { fetch_task . invoke } . not_to raise_error
227+
228+ # 空の news.yml が作成される
229+ expect ( File . exist? ( yaml_path ) ) . to be true
230+ yaml_content = YAML . safe_load ( File . read ( yaml_path ) , permitted_classes : [ Time ] )
231+ expect ( yaml_content [ 'news' ] ) . to eq ( [ ] )
232+ end
233+ end
234+ end
235+
236+ describe 'エラーリカバリー' do
237+ context 'ネットワークエラー後に再実行した場合' do
238+ before do
239+ ENV [ 'NEWS_RSS_PATH' ] = Rails . root . join ( 'spec' , 'fixtures' , 'sample_news.rss' ) . to_s
240+ end
241+
242+ it '正常に処理される' do
243+ # 最初はネットワークエラー
244+ allow_any_instance_of ( Object ) . to receive ( :safe_open ) . and_raise ( Net ::OpenTimeout , 'タイムアウト' )
245+ expect { fetch_task . invoke } . not_to raise_error
246+
247+ # エラー時は空のYAMLが作成される
248+ yaml_content = YAML . safe_load ( File . read ( yaml_path ) , permitted_classes : [ Time ] )
249+ expect ( yaml_content [ 'news' ] ) . to eq ( [ ] )
250+
251+ # safe_openのモックを解除して正常動作に戻す
252+ allow_any_instance_of ( Object ) . to receive ( :safe_open ) . and_call_original
253+
254+ # タスクを再実行可能にする
255+ fetch_task . reenable
256+
257+ # 再実行すると正常に処理される
258+ expect { fetch_task . invoke } . not_to raise_error
259+ yaml_content = YAML . safe_load ( File . read ( yaml_path ) , permitted_classes : [ Time ] )
260+ expect ( yaml_content [ 'news' ] . size ) . to be > 0
261+ end
262+ end
263+ end
264+ end
0 commit comments