バッチ処理とRSpec実装を行いました。
RSpecのtypeに、taskは用意されていないようで、少し手間取ったのでメモしておきます。
あと、引数を渡した場合、true/falseでも文字列型になるので、工夫が必要だった。

タスク作成

$ rails g task user
      create  lib/tasks/user.rake

入れ物のみ実装

先ずは、hello worldを出力するだけにして、Specを通してから先の実装する事にしました。

lib/tasks/user.rake に追加

 namespace :user do
+  desc '削除予定日時を過ぎたユーザーのアカウントを削除'
+  task :destroy do
+    p 'hello world'
+  end
 end

動作確認

$ rails -T
rails user:destroy                       # 削除予定日時を過ぎたユーザーのアカウントを削除
$ rails user:destroy
hello world

テスト実装

spec/lib/tasks/user_spec.rb を作成
(rake_helperで読み込むように変更。こっちの方がハマる事が少ないらしい)

require 'rails_helper'
require 'rake'
require 'rake_helper'

RSpec.describe User do
  before(:all) do
    @rake = Rake::Application.new
    Rake.application = @rake
    Rake.application.rake_require 'tasks/user'
    Rake::Task.define_task(:environment)
  end
  before(:each) do
    @rake[task].reenable
  end

  # 削除予定日時を過ぎたユーザーのアカウントを削除
  describe 'user:destroy' do
    let(:task) { 'user:destroy' }
    let!(:task) { Rake.application['user:destroy'] }

    it 'successful response' do
      expect(@rake[task].invoke).to be_truthy
      expect(task.invoke).to be_truthy
    end
  end
end

spec/rake_helper.rb を作成
(上記変更に伴い追加)

require 'rails_helper'
require 'rake'

RSpec.configure do |config|
  # すべてのタスクを読み込む
  config.before(:suite) do
    Rails.application.load_tasks
  end

  # タスクを毎回実行するようにする
  config.before(:each) do
    Rake.application.tasks.each(&:reenable)
  end
end

動作確認

$ rspec spec/lib/tasks/user_spec.rb
"hello world"
1 example, 0 failures

ログ出力とドライラン追加

バッチ処理は、ログに状況書かないと動いているか解らない。
運用時の困るので出力できるようにしておきます。
また、ドライラン時は標準出力が出た方が便利なので、一緒に。
(ドライランは変更はしないけど、変更対象を確認する為のもの)

lib/tasks/user.rake を変更
(メソッドを共通化。下記、1行目を追加)

+ require './lib/tasks/application.rb'
+
  namespace :user do
   desc '削除予定日時を過ぎたユーザーのアカウントを削除'
-  task :destroy do
-    p 'hello world'
+  task :destroy, [:dry_run] => :environment do |_, args|
+    args.with_defaults(dry_run: 'true')
+    dry_run = (args.dry_run != 'false')
+
+    logger = Logger.new("log/user_destroy_#{Rails.env}.log")
+    logger.info('=== START ===')
+    logger_info_and_puts(dry_run, logger, "dry_run: #{dry_run}")
+
+    # TODO: 処理
+
+    logger.info('=== END ===')
   end
 end
+
+# log出力と、ドライランのみ標準出力
+def logger_info_and_puts(dry_run, logger, message)
+  logger.info(message)
+  p message if dry_run
+end

lib/tasks/application.rb を作成
(上記変更に伴い追加)

# log出力と、ドライランのみ標準出力
def logger_info_and_puts(dry_run, logger, message)
  logger.info(message)
  p message if dry_run
end

動作確認

$ rails -T
rails user:destroy[dry_run]              # 削除予定日時を過ぎたユーザーのアカウントを削除
$ rails user:destroy
"dry_run: true"
$ rails user:destroy[true]
"dry_run: true"
$ rails user:destroy[false]

※log/user_destroy_development.log にも出力される事を確認

$ rspec spec/lib/tasks/user_spec.rb
1 example, 0 failures

※log/user_destroy_test.log にも出力される事を確認

流れを追加

lib/tasks/user.rake を変更

     logger = Logger.new("log/user_destroy_#{Rails.env}.log")
     logger.info('=== START ===')
     logger_info_and_puts(dry_run, logger, "dry_run: #{dry_run}")

+    users = User.where('destroy_schedule_at <= ?', Time.current).order(:destroy_schedule_at).order(:id)
+    logger.debug(users)
+
+    count = users.count
+    logger_info_and_puts(dry_run, logger, "count: #{count}")
+
+    users.each.with_index(1) do |user, index|
+      target = "[#{index}/#{count}] id: #{user.id}, destroy_requested_at: #{user.destroy_requested_at}, destroy_schedule_at: #{user.destroy_schedule_at}"
+      if dry_run
+        logger_info_and_puts(dry_run, logger, "#{target} ... Target of destroy")
+        next
+      end
+
+      logger_info_and_puts(dry_run, logger, "#{target} ... Destroy")
       # TODO: 処理
+    end
+
+    logger.info('=== END ===')

テスト実装

shared_contextできれいに共通化できたと思ったが、テストレコードが増え続けてしまった。
DatabaseCleanerも試したが、増えなくなる(Truncateされる)けど、他のテストケースで作成したレコードの影響を受けてしまった。

RSpec(デフォルト設定)では、before(:each)でtransactionを張って、after(:each)でrollbackされるので、冗長な部分はあるが、下記のように変更しました。

DatabaseCleanerはテスト時間が長くなるので入れたくなかった。
FactoryBot使うとTruncateされないという記事を見かけたが、DB接続して、Truncateされている事も確認できた。

spec/lib/tasks/user_spec.rb に追加

     let!(:task) { Rake.application['user:destroy'] }
+    let!(:dry_run) { 'false' }

-    it 'successful response' do
-      expect(task.invoke).to be_truthy
-    end
+    shared_context '2件(削除予約1件、削除対象0件)' do
+      FactoryBot.create(:user)
+      FactoryBot.create(:user, destroy_schedule_at: Time.current + Settings['destroy_schedule_days'].days - 1.hour)
+    end
+    shared_context '3件(削除予約1件、削除対象1件)' do
+      include_context '2件(削除予約1件、削除対象0件)'
+      FactoryBot.create(:user, destroy_schedule_at: Time.current - Settings['destroy_schedule_days'].days - 1.second)
+    end
+    shared_context '4件(削除予約1件、削除対象2件)' do
+      include_context '3件(削除予約1件、削除対象1件)'
+      FactoryBot.create(:user, destroy_schedule_at: Time.current - Settings['destroy_schedule_days'].days - 1.hour)
+    end
+
+    context '2件(削除予約1件、削除対象0件)' do
+      include_context '2件(削除予約1件、削除対象0件)'
*      before(:each) do
*        FactoryBot.create(:user)
*        FactoryBot.create(:user, destroy_schedule_at: Time.current + Settings['destroy_schedule_days'].days - 1.hour)
*      end
+      it 'ユーザーが削除されない' do
+        expect do
+          expect(task.invoke(dry_run)).to be_truthy
+        end.to change(User, :count).by(0)
+      end
+    end
+    context '3件(削除予約1件、削除対象1件)' do
+      include_context '3件(削除予約1件、削除対象1件)'
*      before(:each) do
*        FactoryBot.create(:user)
*        FactoryBot.create(:user, destroy_schedule_at: Time.current + Settings['destroy_schedule_days'].days - 1.hour)
*        FactoryBot.create(:user, destroy_schedule_at: Time.current - Settings['destroy_schedule_days'].days - 1.second)
*      end
+      it 'ユーザーが1件削除される' do
+        expect do
+          expect(task.invoke(dry_run)).to be_truthy
+        end.to change(User, :count).by(-1)
+      end
+    end
+    context '4件(削除予約1件、削除対象2件)' do
+      include_context '4件(削除予約1件、削除対象2件)'
*      before(:each) do
*        FactoryBot.create(:user)
*        FactoryBot.create(:user, destroy_schedule_at: Time.current + Settings['destroy_schedule_days'].days - 1.hour)
*        FactoryBot.create(:user, destroy_schedule_at: Time.current - Settings['destroy_schedule_days'].days - 1.second)
*        FactoryBot.create(:user, destroy_schedule_at: Time.current - Settings['destroy_schedule_days'].days - 1.hour)
*      end
+      it 'ユーザーが2件削除される' do
+        expect do
+          expect(task.invoke(dry_run)).to be_truthy
+        end.to change(User, :count).by(-2)
+      end
+    end

TODO実装(削除処理)

lib/tasks/user.rake を変更

-      # TODO: 処理
+      unless user.destroy
+        logger.error('User destroy')
+        next
+      end
+
+      # TODO: メール送信

動作確認

$ rspec spec/lib/tasks/user_spec.rb
3 examples, 0 failures
$ rails c
> FactoryBot.create(:user)
> FactoryBot.create(:user, destroy_schedule_at: Time.current + Settings['destroy_schedule_days'].days - 1.hour)
> FactoryBot.create(:user, destroy_schedule_at: Time.current - Settings['destroy_schedule_days'].days - 1.second)
> FactoryBot.create(:user, destroy_schedule_at: Time.current - Settings['destroy_schedule_days'].days - 1.hour)
> User.all.count
 => 6
> exit

$ rails user:destroy
"dry_run: true"
"count: 2"
"[1/2] id: 6, destroy_requested_at: , destroy_schedule_at: 2020-08-07 20:05:28 +0900 ... Target of destroy"
"[2/2] id: 5, destroy_requested_at: , destroy_schedule_at: 2020-08-07 21:05:20 +0900 ... Target of destroy"

$ rails user:destroy[false]
$ rails c
> User.all.count
 => 4
> exit

【参考】ここまでのコミット内容
https://dev.azure.com/nightonly/rails-app-origin/_git/rails-app-origin/commit/c067f59cee0cc1f7de1e3fa42493754838a54d90

TODO実装(メール送信)

書くには多いので、下記参照してください。
https://dev.azure.com/nightonly/rails-app-origin/_git/rails-app-origin/commit/e5b9682ef5d6220802ffb4d7bbeb71c388413183

動作確認

$ rails user:destroy[false]
/usr/bin/open file:tmp/letter_opener/1597410039_015593_f36e8fc/rich.html

LetterOpenerWebでブラウザからも確認できますね。ActionMailer Previewも。

$ rails s
-> http://localhost:3000/letter_opener
-> http://localhost:3000/rails/mailers

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です