※この記事に登場するソースコードは僕個人が開発しているプロジェクトのもので仕事とは無関係です
ざっくり説明
複数の画像を選択して、別のユーザーに送信できるという機能があります。
このアプリケーションにはフォルダという概念があって、フォルダ has_many 画像 という関係になっています。
複数の画像を選択するのが割とめんどくさいことに気付いたのでフォルダをまるごと送信できる機能を実装することにします。
方針を考える
現状画像の送信は POST /api/v1/image_transfer というエンドポイントを叩くと、 Api::V1::ImageTransferControllerのcreateアクションが呼ばれ、そのなかでImageTransferServiceというクラスが呼ばれて、受け取り側のユーザーに紐づく新しいフォルダが作られ、そのフォルダ内に選択した画像が作られます。
今回は新たに FolderTransferService というサービスクラスと
POST /api/v1/folder_transfer というエンドポイントを作って、
Api::V1::FolderTransferControllerのcreateアクションでFolderTransferServiceを呼ぶようにしようと思います。
ImageTransferServiceを使うようにしようかと考えたんですが、フォルダを受け取るように拡張するとクラスがごちゃごちゃになってしまいそうだったのでやめます。
代わりに共通化できそうなところがあればモジュールに切り出したいと思います。
作っていきます
JS側の実装も含めると結構長丁場になりそうなのでのんびりやっていきます
サービスクラスを作る
FolderTransferServiceのテストから書きます。
まずはインターフェースだけ
require 'test_helper' class FolderTransferServiceTest < ActiveSupport::TestCase setup do @sender = users(:kentas) @recipient = users(:some_user1) @folder1 = folders(:kentas_folder1) @image1 = @folder1.images.create(name: 'foo1.png', thumbnail_url: 'foo1_thumbnail.png') @image2 = @folder1.images.create(name: 'foo2.png', thumbnail_url: 'foo2_thumbnail.png') @image3 = @folder1.images.create(name: 'foo3.png', thumbnail_url: 'foo3_thumbnail.png') blob1 = fixture_file_upload(Rails.root.join('test', 'fixtures', 'files', 'kokusai_man.png'), 'image/png') blob2 = fixture_file_upload(Rails.root.join('test', 'fixtures', 'files', 'cat.jpg'), 'image/jpeg') blob3 = fixture_file_upload(Rails.root.join('test', 'fixtures', 'files', 'fruit_lime.png'), 'image/png') @image1.file.attach(io: blob1, filename: @image1.name) @image2.file.attach(io: blob2, filename: @image2.name) @image3.file.attach(io: blob3, filename: @image3.name) end test "send a folder with 3 images to an existing user" do service = FolderTransferService.new( sender: @sender, folder: @folder1, recipient_usernames_or_emails: ['some_user1'], ) assert(service.call) end end
initializeのrecipient_usernames_or_emailsという長い名前の引数は、入力されたユーザーIDまたはEmailアドレスをそのまま受け取ります。
EmailがDBに登録されていない場合は、invitationメールを送信します。
使われ方として、同時に複数人に送ることが多いみたいなので、送信相手は複数入力可能にします。
callメソッドは成功するとtrue, エラーの場合はfalseを返します。
とりあえずインターフェースが決まったのでここまでのテストが通るようにします。
Run options: --seed 31795 # Running: E Error: FolderTransferServiceTest#test_send_a_folder_with_3_images_to_an_existing_user: NameError: uninitialized constant FolderTransferServiceTest::FolderTransferService test/services/folder_transfer_service_test.rb:21:in `block in <class:FolderTransferServiceTest>' rails test test/services/folder_transfer_service_test.rb:20
現時点では当然落ちます。
class FolderTransferService < BaseService def call true end end
BaseServiceのinitializeメソッドでいい感じに引数をattrに変換するように書いているのでこれだけで上で書いたテストはパスします。
Run options: --seed 2812 # Running: . Finished in 0.524547s, 1.9064 runs/s, 1.9064 assertions/s. 1 runs, 1 assertions, 0 failures, 0 errors, 0 skips
次は受け取り側にフォルダが作成されることを確認するテストを追加します
test "send a folder with 3 images to an existing user" do service = FolderTransferService.new( sender: @sender, folder: @folder1, recipient_usernames_or_emails: ['some_user1'], ) assert(service.call) + folder = @recipient.folders.find_by(name: 'kentasさんから') + assert_not_nil(folder) end
フォルダ名についてはどうするか画像の送信の場合もそうなんですが、どうしようか迷っていて、とりあえず受け取った側では「〇〇さんから」という名前になるようにしています。
ちなみに名前が被った場合は「〇〇さんから(1)」のように末尾に情報が追加されるようにFolderモデルを作っています。
UI的に誰から送られてきたのかわかるようになっていないという問題があって、とりあえずの回避策です。
きっちり考えてから作りこむのが理想なんでしょうけど、そのへんは今回はスルーでお願いします。
※あとデフォルトのロケールは:jaです
次はこれを通るようにしていきます
# Running: F Failure: FolderTransferServiceTest#test_send_a_folder_with_3_images_to_an_existing_user [/home/kenta-s/Repositories/oak/test/services/folder_transfer_service_test.rb:28]: Expected nil to not be nil. rails test test/services/folder_transfer_service_test.rb:20
既存のImageTransferServiceに、find_or_create_recipientという、user_idかemailを表す文字列からユーザーを返すメソッドがあるので、これを今回のクラスからも使えるように切り出します。
汚い上に不要なロジックまで入っていてここに貼りたくなかったんですが、自分を戒めるためにそのまま貼ります。
常に誰に見られても恥ずかしくないコードを書くようにしていきたいですね(キリッ)
def find_or_create_recipient(recipient_username_or_email) user = User.find_by(email: recipient_username_or_email) || User.find_by(username: recipient_username_or_email) return user if user unless recipient_username_or_email =~ URI::MailTo::EMAIL_REGEXP error_messages << I18n.t('devise.messages.user_does_not_exist', username_or_email: recipient_username_or_email) return end generated_password = Devise.friendly_token(8) user = User.create_new_user_from_email_skip_confirmation_notification(recipient_username_or_email, generated_password) if user.errors.present? error_messages = error_messages + user.errors.full_messages nil else InvitationMailer.with(user: user, sender: sender, tmp_password: generated_password).welcome.deliver_later user end end
ユーザーが見つかればそのまま返し、そうでなければ作成してinvitationメールを送信しています。
当初はinvitationメールに仮パスワードを表示するつもりだったけど、結局それをやめたのにコードが残っていました。
したがって仮パスワードにDevise.friendly_tokenを使う必要はなくSecureRandomを使うように変更できそうです。
少し寄り道になりますがこれをUserクラスに移す作業を先にやることにします。
既存のImageTransferServiceのテストを壊さないように確認しながらやっていきます
$ bundle exec rails test test/services/image_transfer_service_test.rb Run options: --seed 17002 # Running: ....... Finished in 1.205590s, 5.8063 runs/s, 111.1489 assertions/s. 7 runs, 134 assertions, 0 failures, 0 errors, 0 skips
ちなみにfind_or_create_recipientを削除して走らせると見事に落ちます。
Finished in 0.809332s, 8.6491 runs/s, 13.5915 assertions/s. 7 runs, 11 assertions, 0 failures, 7 errors, 0 skips
余談ですがMiniTestを書き始めたのは最近なんですが、RSpecに比べるとテストの数に対してアサーションが多くなりがちな気がします。
Userモデルに移して余計な処理を取り除く
def self.find_or_create_recipient(sender, recipient_username_or_email) user = self.find_by(email: recipient_username_or_email) || self.find_by(username: recipient_username_or_email) return user if user return nil unless recipient_username_or_email =~ URI::MailTo::EMAIL_REGEXP user = self.create_new_user_from_email_skip_confirmation_notification(recipient_username_or_email) if user.save InvitationMailer.with(user: user, sender: sender).welcome.deliver_later user else nil end end
ImageTransferServiceのfind_or_create_recipient
def find_or_create_recipient(recipient_username_or_email) user = User.find_or_create_recipient(sender, recipient_username_or_email) if user.present? user else error_messages << I18n.t('devise.messages.user_does_not_exist', username_or_email: recipient_username_or_email) nil end end
これで既存のテストは通るようになりました。
Run options: --seed 63892 # Running: ....... Finished in 1.223707s, 5.7203 runs/s, 109.5034 assertions/s. 7 runs, 134 assertions, 0 failures, 0 errors, 0 skips
これでFolderTransferServiceからも使えるようになったので同じように書きます。
def find_or_create_recipient(recipient_username_or_email) user = User.find_or_create_recipient(sender, recipient_username_or_email) if user.present? user else error_messages << I18n.t('devise.messages.user_does_not_exist', username_or_email: recipient_username_or_email) nil end end
initializeが受け取るrecipient_usernames_or_emailsはArrayなのでcallメソッドもそれに対応します。
def call recipient_usernames_or_emails.each do |recipient_username_or_email| recipient = find_or_create_recipient(recipient_username_or_email) # おっと、ここはImageTransferServiceに似た処理があるんでした。 end true end
と思ったんですがImageTransferServiceに似た処理を書いた記憶があるので見てみます
def transfer_images(recipient_username_or_email) recipient = find_or_create_recipient(recipient_username_or_email) return false unless recipient folder = if photo_date.present? taken_at = recipient.photo_dates.find_or_create_by(taken_at: photo_date) taken_at.folders.create(name: folder_name) else recipient.folders.create(name: folder_name) end images.each do |image| next if image.thumbnail_url.nil? copied = folder.images.create( name: image.name, thumbnail_url: image.thumbnail_url, ) copied.file.attach(image.file.blob) end sender.sent_histories.create(recipient: recipient, folder: folder) messages << I18n.t('services.image_transfer_service.sent_images', username_or_email: recipient_username_or_email, image_count: images.size) end
photo_dateって概念があったのを忘れていました。クソっ
これは撮影日という情報を持つモデルで、Folderに紐づきます。 nilでもOKなやつなのでとりあえずこのまま進めます。
このtransfer_imagesはモジュールに切り出そうと思います。
find_or_create_recipientのときと同じように、まずは例のメソッドを削除した状態でテストを走らせて失敗することを確認します
Finished in 0.769953s, 9.0915 runs/s, 14.2866 assertions/s. 7 runs, 11 assertions, 0 failures, 7 errors, 0 skips
モジュールを作成
module ImageTransferable def transfer_images(recipient_username_or_email) recipient = find_or_create_recipient(recipient_username_or_email) return false unless recipient folder = if photo_date.present? taken_at = recipient.photo_dates.find_or_create_by(taken_at: photo_date) taken_at.folders.create(name: folder_name) else recipient.folders.create(name: folder_name) end images.each do |image| next if image.thumbnail_url.nil? copied = folder.images.create( name: image.name, thumbnail_url: image.thumbnail_url, ) copied.file.attach(image.file.blob) end sender.sent_histories.create(recipient: recipient, folder: folder) messages << I18n.t('services.image_transfer_service.sent_images', username_or_email: recipient_username_or_email, image_count: images.size) end end
class ImageTransferService < BaseService
+ include ImageTransferable
... 省略
end
テスト通るようになりました
# Running: ....... Finished in 1.131116s, 6.1886 runs/s, 118.4671 assertions/s. 7 runs, 134 assertions, 0 failures, 0 errors, 0 skips
このtransfer_imagesが参照しているfolder_nameというメソッドがあったのでそれもモジュールに移します。
あとさっきFolderTransferServiceにコピペしたfind_or_create_recipientも移します。
ここまでやると既存のImageTransferServiceは毛刈り後の羊のごとくやせ細ってしまいました。
class ImageTransferService < BaseService include ImageTransferable def call recipient_usernames_or_emails.each do |recipient_username_or_email| transfer_images(recipient_username_or_email) end end end
テストももちろん通ります。
Run options: --seed 60688 # Running: ....... Finished in 1.167899s, 5.9937 runs/s, 114.7360 assertions/s. 7 runs, 134 assertions, 0 failures, 0 errors, 0 skips
ではFolderTransferServiceに戻ります。
imagesだけ定義すれば動きそうな気配があったのでやってみます
class FolderTransferService < BaseService include ImageTransferable def call recipient_usernames_or_emails.each do |recipient_username_or_email| transfer_images(recipient_username_or_email) end true end private def images folder.images end end
ようやく通りました。
Run options: --seed 22586 # Running: . Finished in 0.615516s, 1.6247 runs/s, 3.2493 assertions/s. 1 runs, 2 assertions, 0 failures, 0 errors, 0 skips
なんかもはや動きそうなのでテストに一気にアサーションを追加します
test "send a folder with 3 images to an existing user" do service = FolderTransferService.new( sender: @sender, folder: @folder1, recipient_usernames_or_emails: ['some_user1'], ) assert(service.call) folder = @recipient.folders.find_by(name: 'kentasさんから') assert_not_nil(folder) assert_equal(3, folder.images.count) assert_equal('foo1.png', folder.images[0].name) assert_equal(@image1.file.blob, folder.images[0].file.blob) assert_equal('foo2.png', folder.images[1].name) assert_equal(@image2.file.blob, folder.images[1].file.blob) assert_equal('foo3.png', folder.images[2].name) assert_equal(@image3.file.blob, folder.images[2].file.blob) assert_equal(["some_user1に3個の画像を送信しました"], service.messages) end
通りました
Run options: --seed 13537 # Running: . Finished in 0.575353s, 1.7381 runs/s, 17.3806 assertions/s. 1 runs, 10 assertions, 0 failures, 0 errors, 0 skips
これでとりあえずサービスクラスは完成とします(全体が動くようになったらいくつかテストケースを追加するために戻ってきます)。
長くなってきたので続きは別の記事でやります。