From 5556f3dde8c0ceeb71b644c3f0f00b4b3a0b65cf Mon Sep 17 00:00:00 2001 From: Robbe Van Petegem Date: Sun, 12 Jul 2026 10:15:02 +0200 Subject: [PATCH 1/7] Return errors as json objects instead of strings --- app/controllers/albums_controller.rb | 8 +- app/controllers/application_controller.rb | 12 +++ app/controllers/artists_controller.rb | 8 +- app/controllers/auth_tokens_controller.rb | 4 +- .../codec_conversions_controller.rb | 6 +- app/controllers/codecs_controller.rb | 6 +- app/controllers/cover_filenames_controller.rb | 4 +- app/controllers/genres_controller.rb | 6 +- app/controllers/image_types_controller.rb | 6 +- app/controllers/labels_controller.rb | 6 +- app/controllers/locations_controller.rb | 4 +- app/controllers/playlists_controller.rb | 8 +- app/controllers/plays_controller.rb | 2 +- app/controllers/tracks_controller.rb | 6 +- app/controllers/users_controller.rb | 6 +- config/locales/en.yml | 91 ------------------- test/controllers/albums_controller_test.rb | 6 +- test/controllers/artists_controller_test.rb | 6 +- .../auth_tokens_controller_test.rb | 1 + .../codec_conversions_controller_test.rb | 12 ++- test/controllers/codecs_controller_test.rb | 9 +- .../cover_filenames_controller_test.rb | 1 + test/controllers/genres_controller_test.rb | 6 +- .../image_types_controller_test.rb | 7 +- test/controllers/labels_controller_test.rb | 6 +- test/controllers/locations_controller_test.rb | 1 + test/controllers/playlists_controller_test.rb | 6 +- test/controllers/plays_controller_test.rb | 1 + test/controllers/tracks_controller_test.rb | 7 +- test/controllers/users_controller_test.rb | 6 +- 30 files changed, 101 insertions(+), 157 deletions(-) diff --git a/app/controllers/albums_controller.rb b/app/controllers/albums_controller.rb index 2cd99827..800ff72f 100644 --- a/app/controllers/albums_controller.rb +++ b/app/controllers/albums_controller.rb @@ -29,7 +29,7 @@ def create if @album.save render json: transform_album_for_json(@album), status: :created else - render json: @album.errors, status: :unprocessable_content + render json: transform_errors_for_json(@album), status: :unprocessable_content end end @@ -37,12 +37,12 @@ def update if @album.update(transformed_attributes) render json: transform_album_for_json(@album), status: :ok else - render json: @album.errors, status: :unprocessable_content + render json: transform_errors_for_json(@album), status: :unprocessable_content end end def destroy - render json: @album.errors, status: :unprocessable_content unless @album.destroy + render json: transform_errors_for_json(@album), status: :unprocessable_content unless @album.destroy end def destroy_empty @@ -51,7 +51,7 @@ def destroy_empty end def merge - render json: @album.errors, status: :unprocessable_content unless @album.merge(Album.find(params.expect(:source_id))) + render json: transform_errors_for_json(@album), status: :unprocessable_content unless @album.merge(Album.find(params.expect(:source_id))) end private diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index cfcfb67d..aa6b06e5 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -2,6 +2,9 @@ class ApplicationController < ActionController::API include Pundit::Authorization include ActionController::HttpAuthentication::Token::ControllerMethods + # This map only includes the type of validation errors that we could have inside the app + ERROR_TYPE_MAP = { blank: :required, taken: :not_unique }.freeze + etag { params[:page] } etag { params[:per_page] } @@ -42,6 +45,15 @@ def stale?(scope:, **) super(etag:, **) end + def transform_error_for_json(error) + { attribute: error.attribute, type: ERROR_TYPE_MAP[error.type] } + end + + # This method expects an instance of a class that includes `ActiveModel::Errors` + def transform_errors_for_json(object) + { errors: object.errors.errors.map { transform_error_for_json(it) } } + end + private def authenticate_user diff --git a/app/controllers/artists_controller.rb b/app/controllers/artists_controller.rb index a53736b0..b5af9a98 100644 --- a/app/controllers/artists_controller.rb +++ b/app/controllers/artists_controller.rb @@ -26,7 +26,7 @@ def create if @artist.save render json: transform_artist_for_json(@artist), status: :created else - render json: @artist.errors, status: :unprocessable_content + render json: transform_errors_for_json(@artist), status: :unprocessable_content end end @@ -34,12 +34,12 @@ def update if @artist.update(transformed_attributes) render json: transform_artist_for_json(@artist), status: :ok else - render json: @artist.errors, status: :unprocessable_content + render json: transform_errors_for_json(@artist), status: :unprocessable_content end end def destroy - render json: @artist.errors, status: :unprocessable_content unless @artist.destroy + render json: transform_errors_for_json(@artist), status: :unprocessable_content unless @artist.destroy end def destroy_empty @@ -51,7 +51,7 @@ def destroy_empty end def merge - render json: @artist.errors, status: :unprocessable_content unless @artist.merge(Artist.find(params.expect(:source_id))) + render json: transform_errors_for_json(@artist), status: :unprocessable_content unless @artist.merge(Artist.find(params.expect(:source_id))) end private diff --git a/app/controllers/auth_tokens_controller.rb b/app/controllers/auth_tokens_controller.rb index 294fb671..b15e5fa5 100644 --- a/app/controllers/auth_tokens_controller.rb +++ b/app/controllers/auth_tokens_controller.rb @@ -33,12 +33,12 @@ def create if @auth_token.save render json: transform_auth_token_for_json_with_token(@auth_token), status: :created else - render json: @auth_token.errors, status: :unprocessable_content + render json: transform_errors_for_json(@auth_token), status: :unprocessable_content end end def destroy - render json: @auth_token.errors, status: :unprocessable_content unless @auth_token.destroy + render json: transform_errors_for_json(@auth_token), status: :unprocessable_content unless @auth_token.destroy end private diff --git a/app/controllers/codec_conversions_controller.rb b/app/controllers/codec_conversions_controller.rb index f0af3e32..dd0f169c 100644 --- a/app/controllers/codec_conversions_controller.rb +++ b/app/controllers/codec_conversions_controller.rb @@ -23,7 +23,7 @@ def create if @codec_conversion.save render json: transform_codec_conversion_for_json(@codec_conversion), status: :created else - render json: @codec_conversion.errors, status: :unprocessable_content + render json: transform_errors_for_json(@codec_conversion), status: :unprocessable_content end end @@ -31,12 +31,12 @@ def update if @codec_conversion.update(permitted_attributes(CodecConversion)) render json: transform_codec_conversion_for_json(@codec_conversion), status: :ok else - render json: @codec_conversion.errors, status: :unprocessable_content + render json: transform_errors_for_json(@codec_conversion), status: :unprocessable_content end end def destroy - render json: @codec_conversion.errors, status: :unprocessable_content unless @codec_conversion.destroy + render json: transform_errors_for_json(@codec_conversion), status: :unprocessable_content unless @codec_conversion.destroy end private diff --git a/app/controllers/codecs_controller.rb b/app/controllers/codecs_controller.rb index 4b5ea0d2..b25ad3ad 100644 --- a/app/controllers/codecs_controller.rb +++ b/app/controllers/codecs_controller.rb @@ -21,7 +21,7 @@ def create if @codec.save render json: transform_codec_for_json(@codec), status: :created else - render json: @codec.errors, status: :unprocessable_content + render json: transform_errors_for_json(@codec), status: :unprocessable_content end end @@ -29,12 +29,12 @@ def update if @codec.update(permitted_attributes(@codec)) render json: transform_codec_for_json(@codec), status: :ok else - render json: @codec.errors, status: :unprocessable_content + render json: transform_errors_for_json(@codec), status: :unprocessable_content end end def destroy - render json: @codec.errors, status: :unprocessable_content unless @codec.destroy + render json: transform_errors_for_json(@codec), status: :unprocessable_content unless @codec.destroy end private diff --git a/app/controllers/cover_filenames_controller.rb b/app/controllers/cover_filenames_controller.rb index 2cedc366..a5b06daf 100644 --- a/app/controllers/cover_filenames_controller.rb +++ b/app/controllers/cover_filenames_controller.rb @@ -21,12 +21,12 @@ def create if @cover_filename.save render json: transform_cover_filename_for_json(@cover_filename), status: :created else - render json: @cover_filename.errors, status: :unprocessable_content + render json: transform_errors_for_json(@cover_filename), status: :unprocessable_content end end def destroy - render json: @cover_filename.errors, status: :unprocessable_content unless @cover_filename.destroy + render json: transform_errors_for_json(@cover_filename), status: :unprocessable_content unless @cover_filename.destroy end private diff --git a/app/controllers/genres_controller.rb b/app/controllers/genres_controller.rb index 395476ca..b75b113d 100644 --- a/app/controllers/genres_controller.rb +++ b/app/controllers/genres_controller.rb @@ -21,7 +21,7 @@ def create if @genre.save render json: transform_genre_for_json(@genre), status: :created else - render json: @genre.errors, status: :unprocessable_content + render json: transform_errors_for_json(@genre), status: :unprocessable_content end end @@ -29,12 +29,12 @@ def update if @genre.update(permitted_attributes(@genre)) render json: transform_genre_for_json(@genre), status: :ok else - render json: @genre.errors, status: :unprocessable_content + render json: transform_errors_for_json(@genre), status: :unprocessable_content end end def destroy - render json: @genre.errors, status: :unprocessable_content unless @genre.destroy + render json: transform_errors_for_json(@genre), status: :unprocessable_content unless @genre.destroy end def destroy_empty diff --git a/app/controllers/image_types_controller.rb b/app/controllers/image_types_controller.rb index 4fa7fbd7..d9d71d0a 100644 --- a/app/controllers/image_types_controller.rb +++ b/app/controllers/image_types_controller.rb @@ -21,7 +21,7 @@ def create if @image_type.save render json: transform_image_type_for_json(@image_type), status: :created else - render json: @image_type.errors, status: :unprocessable_content + render json: transform_errors_for_json(@image_type), status: :unprocessable_content end end @@ -29,12 +29,12 @@ def update if @image_type.update(permitted_attributes(@image_type)) render json: transform_image_type_for_json(@image_type), status: :ok else - render json: @image_type.errors, status: :unprocessable_content + render json: transform_errors_for_json(@image_type), status: :unprocessable_content end end def destroy - render json: @image_type.errors, status: :unprocessable_content unless @image_type.destroy + render json: transform_errors_for_json(@image_type), status: :unprocessable_content unless @image_type.destroy end private diff --git a/app/controllers/labels_controller.rb b/app/controllers/labels_controller.rb index 7ff4e58c..37383420 100644 --- a/app/controllers/labels_controller.rb +++ b/app/controllers/labels_controller.rb @@ -21,7 +21,7 @@ def create if @label.save render json: transform_label_for_json(@label), status: :created else - render json: @label.errors, status: :unprocessable_content + render json: transform_errors_for_json(@label), status: :unprocessable_content end end @@ -29,12 +29,12 @@ def update if @label.update(permitted_attributes(@label)) render json: transform_label_for_json(@label), status: :ok else - render json: @label.errors, status: :unprocessable_content + render json: transform_errors_for_json(@label), status: :unprocessable_content end end def destroy - render json: @label.errors, status: :unprocessable_content unless @label.destroy + render json: transform_errors_for_json(@label), status: :unprocessable_content unless @label.destroy end def destroy_empty diff --git a/app/controllers/locations_controller.rb b/app/controllers/locations_controller.rb index 427aa940..0f2a0fb0 100644 --- a/app/controllers/locations_controller.rb +++ b/app/controllers/locations_controller.rb @@ -21,12 +21,12 @@ def create if @location.save render json: transform_location_for_json(@location), status: :created else - render json: @location.errors, status: :unprocessable_content + render json: transform_errors_for_json(@location), status: :unprocessable_content end end def destroy - render json: @location.errors, status: :unprocessable_content unless @location.destroy + render json: transform_errors_for_json(@location), status: :unprocessable_content unless @location.destroy end private diff --git a/app/controllers/playlists_controller.rb b/app/controllers/playlists_controller.rb index 84effa70..588b3cfc 100644 --- a/app/controllers/playlists_controller.rb +++ b/app/controllers/playlists_controller.rb @@ -21,7 +21,7 @@ def create if @playlist.save render json: transform_playlist_for_json(@playlist), status: :created else - render json: @playlist.errors, status: :unprocessable_content + render json: transform_errors_for_json(@playlist), status: :unprocessable_content end end @@ -29,18 +29,18 @@ def update if @playlist.update(permitted_attributes(@playlist)) render json: transform_playlist_for_json(@playlist), status: :ok else - render json: @playlist.errors, status: :unprocessable_content + render json: transform_errors_for_json(@playlist), status: :unprocessable_content end end def destroy - render json: @playlist.errors, status: :unprocessable_content unless @playlist.destroy + render json: transform_errors_for_json(@playlist), status: :unprocessable_content unless @playlist.destroy end def add_item @item = @playlist.items.create(permitted_attributes(@playlist)) - render json: @item.errors, status: :unprocessable_content unless @item.save + render json: transform_errors_for_json(@item), status: :unprocessable_content unless @item.save end private diff --git a/app/controllers/plays_controller.rb b/app/controllers/plays_controller.rb index 52ef0b69..8036bc2c 100644 --- a/app/controllers/plays_controller.rb +++ b/app/controllers/plays_controller.rb @@ -20,7 +20,7 @@ def create if @play.save render json: transform_play_for_json(@play), status: :created else - render json: @play.errors, status: :unprocessable_content + render json: transform_errors_for_json(@play), status: :unprocessable_content end end diff --git a/app/controllers/tracks_controller.rb b/app/controllers/tracks_controller.rb index 0ff5c5cd..fea7f5c3 100644 --- a/app/controllers/tracks_controller.rb +++ b/app/controllers/tracks_controller.rb @@ -26,7 +26,7 @@ def create if @track.save render json: transform_track_for_json(@track), status: :created else - render json: @track.errors, status: :unprocessable_content + render json: transform_errors_for_json(@track), status: :unprocessable_content end end @@ -34,12 +34,12 @@ def update if @track.update(transformed_attributes) render json: transform_track_for_json(@track), status: :ok else - render json: @track.errors, status: :unprocessable_content + render json: transform_errors_for_json(@track), status: :unprocessable_content end end def destroy - render json: @track.errors, status: :unprocessable_content unless @track.destroy + render json: transform_errors_for_json(@track), status: :unprocessable_content unless @track.destroy end def destroy_empty diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index cba20173..8ce23bef 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -21,7 +21,7 @@ def create if @user.save render json: transform_user_for_json(@user), status: :created else - render json: @user.errors, status: :unprocessable_content + render json: transform_errors_for_json(@user), status: :unprocessable_content end end @@ -37,12 +37,12 @@ def update if @user.update(permitted_attributes(@user)) render json: transform_user_for_json(@user), status: :ok else - render json: @user.errors, status: :unprocessable_content + render json: transform_errors_for_json(@user), status: :unprocessable_content end end def destroy - render json: @user.errors, status: :unprocessable_content unless @user.destroy + render json: transform_errors_for_json(@user), status: :unprocessable_content unless @user.destroy end private diff --git a/config/locales/en.yml b/config/locales/en.yml index 7126d8a8..a5fac261 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -30,97 +30,6 @@ # available at http://guides.rubyonrails.org/i18n.html. en: - activerecord: - errors: - models: - album: - attributes: - title: - blank: "albums.title-blank" - album_artists: - attributes: - name: - blank: "aa.name-blank" - order: - blank: "aa.order-blank" - album_label: - attributes: - catalogue_number: - blank: "al.cat-blank" - artist: - attributes: - name: - blank: "artists.name-blank" - codec: - attributes: - mimetype: - blank: "codec.mime-blank" - extension: - blank: "codec.ext-blank" - taken: "codec.ext-taken" - codec_conversion: - attributes: - name: - blank: "codecconv.nane-blank" - taken: "codecconv.name-taken" - ffmpeg_params: - blank: "codecconv.ffmpeg-blank" - resulting_codec: - blank: "codecconv.result-blank" - genre: - attributes: - name: - blank: "genre.name-blank" - taken: "genre.name-taken" - image_type: - attributes: - mimetype: - blank: "image.mime-blank" - extension: - blank: "image.ext-blank" - taken: "image.ext-taken" - label: - attributes: - name: - blank: "label.name-blank" - location: - attributes: - path: - blank: "location.path-blank" - taken: "location.path-taken" - play: - attributes: - played_at: - blank: "play.played-at-blank" - track: - attributes: - title: - blank: "tracks.title-blank" - number: - blank: "tracks.number-blank" - track_artist: - attributes: - artist: - blank: "ta.artist-blank" - name: - blank: "ta.name-blank" - taken: "ta.name-taken" - order: - blank: "ta.order-blank" - role: - blank: "ta.role-blank" - track: - blank: "ta.track-blank" - user: - attributes: - name: - blank: "user.name-blank" - password_confirmation: - confirmation: "user.password-confirmation" - password_digest: - blank: "user.password-blank" - permission: - blank: "user.permission-blank" auth_tokens: create: wrong_credentials: 'user.wrong-credentials' diff --git a/test/controllers/albums_controller_test.rb b/test/controllers/albums_controller_test.rb index 62ef41df..5cf1e51a 100644 --- a/test/controllers/albums_controller_test.rb +++ b/test/controllers/albums_controller_test.rb @@ -74,6 +74,7 @@ class AlbumsControllerTest < ActionDispatch::IntegrationTest end assert_response :unprocessable_content + assert_includes response.parsed_body['errors'], { 'attribute' => 'title', 'type' => 'required' } end test 'should create dependent album_labels' do @@ -153,9 +154,8 @@ class AlbumsControllerTest < ActionDispatch::IntegrationTest patch album_url(@album), params: { album: { release: @album.release, title: '' } } assert_response :unprocessable_content - @album.reload - - assert_not_equal '', @album.title + assert_not_equal '', @album.reload.title + assert_includes response.parsed_body['errors'], { 'attribute' => 'title', 'type' => 'required' } end test 'should clear review comment' do diff --git a/test/controllers/artists_controller_test.rb b/test/controllers/artists_controller_test.rb index 6a701e59..db61ad99 100644 --- a/test/controllers/artists_controller_test.rb +++ b/test/controllers/artists_controller_test.rb @@ -69,6 +69,7 @@ class ArtistsControllerTest < ActionDispatch::IntegrationTest end assert_response :unprocessable_content + assert_includes response.parsed_body['errors'], { 'attribute' => 'name', 'type' => 'required' } end test 'should show artist' do @@ -91,9 +92,8 @@ class ArtistsControllerTest < ActionDispatch::IntegrationTest patch artist_url(@artist), params: { artist: { name: '' } } assert_response :unprocessable_content - @artist.reload - - assert_not_equal '', @artist.name + assert_not_equal '', @artist.reload.name + assert_includes response.parsed_body['errors'], { 'attribute' => 'name', 'type' => 'required' } end test 'should not update artist metadata for user' do diff --git a/test/controllers/auth_tokens_controller_test.rb b/test/controllers/auth_tokens_controller_test.rb index 942f8f48..95a0e410 100644 --- a/test/controllers/auth_tokens_controller_test.rb +++ b/test/controllers/auth_tokens_controller_test.rb @@ -74,6 +74,7 @@ class AuthTokensControllerTest < ActionDispatch::IntegrationTest end assert_response :unprocessable_content + assert_includes response.parsed_body['errors'], { 'attribute' => 'user_agent', 'type' => 'required' } end test 'should show auth_token' do diff --git a/test/controllers/codec_conversions_controller_test.rb b/test/controllers/codec_conversions_controller_test.rb index 3691eaeb..e68ce2c4 100644 --- a/test/controllers/codec_conversions_controller_test.rb +++ b/test/controllers/codec_conversions_controller_test.rb @@ -90,6 +90,7 @@ class CodecConversionsControllerTest < ActionDispatch::IntegrationTest end assert_response :unprocessable_content + assert_includes response.parsed_body['errors'], { 'attribute' => 'name', 'type' => 'not_unique' } end test 'should not create codec_conversion with empty ffmpeg_params' do @@ -103,6 +104,7 @@ class CodecConversionsControllerTest < ActionDispatch::IntegrationTest end assert_response :unprocessable_content + assert_includes response.parsed_body['errors'], { 'attribute' => 'ffmpeg_params', 'type' => 'required' } end test 'should not create codec_conversion with empty name' do @@ -116,6 +118,7 @@ class CodecConversionsControllerTest < ActionDispatch::IntegrationTest end assert_response :unprocessable_content + assert_includes response.parsed_body['errors'], { 'attribute' => 'name', 'type' => 'required' } end test 'should not create codec_conversion with non-existing resulting_codec' do @@ -130,6 +133,7 @@ class CodecConversionsControllerTest < ActionDispatch::IntegrationTest end assert_response :unprocessable_content + assert_includes response.parsed_body['errors'], { 'attribute' => 'resulting_codec', 'type' => 'required' } end test 'should create codec_conversion for admin' do @@ -175,11 +179,13 @@ class CodecConversionsControllerTest < ActionDispatch::IntegrationTest test 'should not update codec_conversion to empty name' do sign_in_as(create(:moderator)) - patch codec_conversion_url(@codec_conversion), params: { codec_conversion: { - name: '' - } } + + assert_no_changes '@codec_conversion.reload.name' do + patch codec_conversion_url(@codec_conversion), params: { codec_conversion: { name: '' } } + end assert_response :unprocessable_content + assert_includes response.parsed_body['errors'], { 'attribute' => 'name', 'type' => 'required' } end test 'should update codec_conversion for admin' do diff --git a/test/controllers/codecs_controller_test.rb b/test/controllers/codecs_controller_test.rb index 5a47c906..1769cc59 100644 --- a/test/controllers/codecs_controller_test.rb +++ b/test/controllers/codecs_controller_test.rb @@ -50,16 +50,19 @@ class CodecsControllerTest < ActionDispatch::IntegrationTest end assert_response :unprocessable_content + assert_includes response.parsed_body['errors'], { 'attribute' => 'extension', 'type' => 'required' } end test 'should not create codec with missing mimetype' do sign_in_as(create(:moderator)) codec = build(:codec) + assert_difference('Codec.count', 0) do post codecs_url, params: { codec: { extension: codec.extension } } end assert_response :unprocessable_content + assert_includes response.parsed_body['errors'], { 'attribute' => 'mimetype', 'type' => 'required' } end test 'should create codec for moderator' do @@ -96,9 +99,13 @@ class CodecsControllerTest < ActionDispatch::IntegrationTest test 'should not update codec when clearing mimetype' do sign_in_as(create(:moderator)) - patch codec_url(@codec), params: { codec: { mimetype: '' } } + + assert_no_changes '@codec.reload.mimetype' do + patch codec_url(@codec), params: { codec: { mimetype: '' } } + end assert_response :unprocessable_content + assert_includes response.parsed_body['errors'], { 'attribute' => 'mimetype', 'type' => 'required' } end test 'should update codec for moderator' do diff --git a/test/controllers/cover_filenames_controller_test.rb b/test/controllers/cover_filenames_controller_test.rb index 40b5a0c6..37fee62d 100644 --- a/test/controllers/cover_filenames_controller_test.rb +++ b/test/controllers/cover_filenames_controller_test.rb @@ -68,6 +68,7 @@ class CoverFilenamesControllerTest < ActionDispatch::IntegrationTest end assert_response :unprocessable_content + assert_includes response.parsed_body['errors'], { 'attribute' => 'filename', 'type' => 'required' } end test 'should create cover_filename for moderator' do diff --git a/test/controllers/genres_controller_test.rb b/test/controllers/genres_controller_test.rb index 8597d9fd..ee744108 100644 --- a/test/controllers/genres_controller_test.rb +++ b/test/controllers/genres_controller_test.rb @@ -49,6 +49,7 @@ class GenresControllerTest < ActionDispatch::IntegrationTest end assert_response :unprocessable_content + assert_includes response.parsed_body['errors'], { 'attribute' => 'name', 'type' => 'required' } end test 'should create genre for moderator' do @@ -88,9 +89,8 @@ class GenresControllerTest < ActionDispatch::IntegrationTest patch genre_url(@genre), params: { genre: { name: '' } } assert_response :unprocessable_content - @genre.reload - - assert_not_equal '', @genre.name + assert_not_equal '', @genre.reload.name + assert_includes response.parsed_body['errors'], { 'attribute' => 'name', 'type' => 'required' } end test 'should update genre for moderator' do diff --git a/test/controllers/image_types_controller_test.rb b/test/controllers/image_types_controller_test.rb index 8f4b6f94..098ec72b 100644 --- a/test/controllers/image_types_controller_test.rb +++ b/test/controllers/image_types_controller_test.rb @@ -50,6 +50,7 @@ class ImageTypesControllerTest < ActionDispatch::IntegrationTest end assert_response :unprocessable_content + assert_includes response.parsed_body['errors'], { 'attribute' => 'extension', 'type' => 'required' } end test 'should not create image_type without mimetype' do @@ -60,6 +61,7 @@ class ImageTypesControllerTest < ActionDispatch::IntegrationTest end assert_response :unprocessable_content + assert_includes response.parsed_body['errors'], { 'attribute' => 'mimetype', 'type' => 'required' } end test 'should create image_type for moderator' do @@ -99,9 +101,8 @@ class ImageTypesControllerTest < ActionDispatch::IntegrationTest patch image_type_url(@image_type), params: { image_type: { mimetype: '' } } assert_response :unprocessable_content - @image_type.reload - - assert_not_equal '', @image_type.mimetype + assert_not_equal '', @image_type.reload.mimetype + assert_includes response.parsed_body['errors'], { 'attribute' => 'mimetype', 'type' => 'required' } end test 'should update image_type for moderator' do diff --git a/test/controllers/labels_controller_test.rb b/test/controllers/labels_controller_test.rb index a097fb74..87280c96 100644 --- a/test/controllers/labels_controller_test.rb +++ b/test/controllers/labels_controller_test.rb @@ -50,6 +50,7 @@ class LabelsControllerTest < ActionDispatch::IntegrationTest end assert_response :unprocessable_content + assert_includes response.parsed_body['errors'], { 'attribute' => 'name', 'type' => 'required' } end test 'should create label for moderator' do @@ -89,9 +90,8 @@ class LabelsControllerTest < ActionDispatch::IntegrationTest patch label_url(@label), params: { label: { name: '' } } assert_response :unprocessable_content - @label.reload - - assert_not_equal '', @label.name + assert_not_equal '', @label.reload.name + assert_includes response.parsed_body['errors'], { 'attribute' => 'name', 'type' => 'required' } end test 'should update label for moderator' do diff --git a/test/controllers/locations_controller_test.rb b/test/controllers/locations_controller_test.rb index e6f92f44..b1e86d72 100644 --- a/test/controllers/locations_controller_test.rb +++ b/test/controllers/locations_controller_test.rb @@ -68,6 +68,7 @@ class LocationsControllerTest < ActionDispatch::IntegrationTest end assert_response :unprocessable_content + assert_includes response.parsed_body['errors'], { 'attribute' => 'path', 'type' => 'required' } end test 'should create location for moderator' do diff --git a/test/controllers/playlists_controller_test.rb b/test/controllers/playlists_controller_test.rb index 276c80af..aec8011e 100644 --- a/test/controllers/playlists_controller_test.rb +++ b/test/controllers/playlists_controller_test.rb @@ -66,6 +66,7 @@ class PlaylistsControllerTest < ActionDispatch::IntegrationTest end assert_response :unprocessable_content + assert_includes response.parsed_body['errors'], { 'attribute' => 'name', 'type' => 'required' } end test 'should create personal playlist for current user if specified' do @@ -97,9 +98,12 @@ class PlaylistsControllerTest < ActionDispatch::IntegrationTest end test 'should not update playlist with empty name' do - patch playlist_url(@playlist), params: { playlist: { name: '' } } + assert_no_changes '@playlist.reload.name' do + patch playlist_url(@playlist), params: { playlist: { name: '' } } + end assert_response :unprocessable_content + assert_includes response.parsed_body['errors'], { 'attribute' => 'name', 'type' => 'required' } end test 'should create playlist items during update' do diff --git a/test/controllers/plays_controller_test.rb b/test/controllers/plays_controller_test.rb index 9f8b3d32..93f90827 100644 --- a/test/controllers/plays_controller_test.rb +++ b/test/controllers/plays_controller_test.rb @@ -75,6 +75,7 @@ class PlaysControllerTest < ActionDispatch::IntegrationTest end assert_response :unprocessable_content + assert_includes response.parsed_body['errors'], { 'attribute' => 'played_at', 'type' => 'required' } end test 'should get stats and not return play stats for other users' do diff --git a/test/controllers/tracks_controller_test.rb b/test/controllers/tracks_controller_test.rb index aea0e41e..0d90a7f2 100644 --- a/test/controllers/tracks_controller_test.rb +++ b/test/controllers/tracks_controller_test.rb @@ -72,6 +72,7 @@ class TracksControllerTest < ActionDispatch::IntegrationTest end assert_response :unprocessable_content + assert_includes response.parsed_body['errors'], { 'attribute' => 'title', 'type' => 'required' } end test 'should not create track without album_id' do @@ -81,6 +82,7 @@ class TracksControllerTest < ActionDispatch::IntegrationTest end assert_response :unprocessable_content + assert_includes response.parsed_body['errors'], { 'attribute' => 'album', 'type' => 'required' } end test 'should create track for moderator' do @@ -156,9 +158,8 @@ class TracksControllerTest < ActionDispatch::IntegrationTest patch track_url(@track), params: { track: { title: '' } } assert_response :unprocessable_content - @track.reload - - assert_not_equal '', @track.title + assert_not_equal '', @track.reload.title + assert_includes response.parsed_body['errors'], { 'attribute' => 'title', 'type' => 'required' } end test 'should clear review comment' do diff --git a/test/controllers/users_controller_test.rb b/test/controllers/users_controller_test.rb index b66db3d1..c0c2bcf9 100644 --- a/test/controllers/users_controller_test.rb +++ b/test/controllers/users_controller_test.rb @@ -50,6 +50,7 @@ class UsersControllerTest < ActionDispatch::IntegrationTest end assert_response :unprocessable_content + assert_includes response.parsed_body['errors'], { 'attribute' => 'name', 'type' => 'required' } end test 'should create user for admin' do @@ -98,9 +99,8 @@ class UsersControllerTest < ActionDispatch::IntegrationTest patch user_url(@user), params: { user: { name: '' } } assert_response :unprocessable_content - @user.reload - - assert_not_equal '', @user.name + assert_not_equal '', @user.reload.name + assert_includes response.parsed_body['errors'], { 'attribute' => 'name', 'type' => 'required' } end test 'should not update own permission if not admin' do From a8b7f48c6f549c4f33290f33635ed99e028a5f83 Mon Sep 17 00:00:00 2001 From: Robbe Van Petegem Date: Sun, 12 Jul 2026 10:26:57 +0200 Subject: [PATCH 2/7] Convert loose errors to consistent format --- app/controllers/application_controller.rb | 9 +---- app/controllers/auth_tokens_controller.rb | 3 +- app/controllers/users_controller.rb | 3 +- config/locales/en.yml | 39 ------------------- test/controllers/albums_controller_test.rb | 4 ++ test/controllers/artists_controller_test.rb | 4 ++ .../auth_tokens_controller_test.rb | 1 + .../codec_conversions_controller_test.rb | 3 ++ test/controllers/codecs_controller_test.rb | 3 ++ .../cover_filenames_controller_test.rb | 4 ++ test/controllers/genres_controller_test.rb | 5 +++ .../image_types_controller_test.rb | 3 ++ test/controllers/labels_controller_test.rb | 5 +++ test/controllers/locations_controller_test.rb | 4 ++ test/controllers/playlists_controller_test.rb | 6 +++ test/controllers/rescans_controller_test.rb | 4 ++ test/controllers/tracks_controller_test.rb | 7 ++++ test/controllers/users_controller_test.rb | 2 + 18 files changed, 59 insertions(+), 50 deletions(-) delete mode 100644 config/locales/en.yml diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index aa6b06e5..99a0a963 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -64,16 +64,11 @@ def authenticate_user end def user_not_authorized(exc) - policy_name = exc.policy.class.to_s.underscore - status = current_user.present? ? :forbidden : :unauthorized - render json: { status => [I18n.t("#{policy_name}.#{exc.query}", - scope: 'pundit', - default: :default)] }, - status: + render json: { errors: [{ policy: exc.policy.class.to_s.underscore, type: status, action: exc.query }] }, status: end def model_not_found(exc) - render json: { not_found: ["#{exc.model.pluralize.downcase}.not-found"] }, status: :not_found + render json: { errors: [{ model: exc.model.downcase, type: :not_found }] }, status: :not_found end end diff --git a/app/controllers/auth_tokens_controller.rb b/app/controllers/auth_tokens_controller.rb index b15e5fa5..f646ec8d 100644 --- a/app/controllers/auth_tokens_controller.rb +++ b/app/controllers/auth_tokens_controller.rb @@ -19,8 +19,7 @@ def create user = User.find_by(name: params[:name]) unless user.try(:authenticate, params[:password]) - render json: { unauthorized: [I18n.t('auth_tokens.create.wrong_credentials')] }, - status: :unauthorized + render json: { errors: [{ attribute: :base, type: :wrong_credentials }] }, status: :unauthorized return end diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 8ce23bef..df5efbdd 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -29,8 +29,7 @@ def update if @user == current_user && params[:user][:password].present? && !@user.try(:authenticate, params[:user][:current_password]) - render json: { unauthorized: [I18n.t('users.current_password_is_incorrect')] }, - status: :unauthorized + render json: { errors: [{ attribute: :base, type: :incorrect_password }] }, status: :unauthorized return end diff --git a/config/locales/en.yml b/config/locales/en.yml deleted file mode 100644 index a5fac261..00000000 --- a/config/locales/en.yml +++ /dev/null @@ -1,39 +0,0 @@ -# Files in the config/locales directory are used for internationalization -# and are automatically loaded by Rails. If you want to use locales other -# than English, add the necessary files in this directory. -# -# To use the locales, use `I18n.t`: -# -# I18n.t 'hello' -# -# In views, this is aliased to just `t`: -# -# <%= t('hello') %> -# -# To use a different locale, set it with `I18n.locale`: -# -# I18n.locale = :es -# -# This would use the information in config/locales/es.yml. -# -# The following keys must be escaped otherwise they will not be retrieved by -# the default I18n backend: -# -# true, false, on, off, yes, no -# -# Instead, surround them with single quotes. -# -# en: -# 'true': 'foo' -# -# To learn more, please read the Rails Internationalization guide -# available at http://guides.rubyonrails.org/i18n.html. - -en: - auth_tokens: - create: - wrong_credentials: 'user.wrong-credentials' - users: - current_password_is_incorrect: 'user.current-password-incorrect' - pundit: - default: 'not-authorized' diff --git a/test/controllers/albums_controller_test.rb b/test/controllers/albums_controller_test.rb index 5cf1e51a..8c4b94eb 100644 --- a/test/controllers/albums_controller_test.rb +++ b/test/controllers/albums_controller_test.rb @@ -39,6 +39,7 @@ class AlbumsControllerTest < ActionDispatch::IntegrationTest end assert_response :forbidden + assert_includes response.parsed_body['errors'], { 'policy' => 'album_policy', 'type' => 'forbidden', 'action' => 'create?' } end test 'should create album for moderator' do @@ -229,6 +230,7 @@ class AlbumsControllerTest < ActionDispatch::IntegrationTest end assert_response :forbidden + assert_includes response.parsed_body['errors'], { 'policy' => 'album_policy', 'type' => 'forbidden', 'action' => 'destroy?' } end test 'should destroy album for moderator' do @@ -251,6 +253,7 @@ class AlbumsControllerTest < ActionDispatch::IntegrationTest end assert_response :forbidden + assert_includes response.parsed_body['errors'], { 'policy' => 'album_policy', 'type' => 'forbidden', 'action' => 'destroy_empty?' } end test 'should destroy empty albums for moderator' do @@ -279,6 +282,7 @@ class AlbumsControllerTest < ActionDispatch::IntegrationTest end assert_response :forbidden + assert_includes response.parsed_body['errors'], { 'policy' => 'album_policy', 'type' => 'forbidden', 'action' => 'merge?' } end test 'should merge albums for moderator' do diff --git a/test/controllers/artists_controller_test.rb b/test/controllers/artists_controller_test.rb index db61ad99..7eb3c270 100644 --- a/test/controllers/artists_controller_test.rb +++ b/test/controllers/artists_controller_test.rb @@ -39,6 +39,7 @@ class ArtistsControllerTest < ActionDispatch::IntegrationTest end assert_response :forbidden + assert_includes response.parsed_body['errors'], { 'policy' => 'artist_policy', 'type' => 'forbidden', 'action' => 'create?' } end test 'should create artist for moderator' do @@ -176,6 +177,7 @@ class ArtistsControllerTest < ActionDispatch::IntegrationTest end assert_response :forbidden + assert_includes response.parsed_body['errors'], { 'policy' => 'artist_policy', 'type' => 'forbidden', 'action' => 'destroy?' } end test 'should destroy artist for moderator' do @@ -198,6 +200,7 @@ class ArtistsControllerTest < ActionDispatch::IntegrationTest end assert_response :forbidden + assert_includes response.parsed_body['errors'], { 'policy' => 'artist_policy', 'type' => 'forbidden', 'action' => 'destroy_empty?' } end test 'should destroy empty artists for moderator (track_artist)' do @@ -248,6 +251,7 @@ class ArtistsControllerTest < ActionDispatch::IntegrationTest end assert_response :forbidden + assert_includes response.parsed_body['errors'], { 'policy' => 'artist_policy', 'type' => 'forbidden', 'action' => 'merge?' } end test 'should merge artists for moderator' do diff --git a/test/controllers/auth_tokens_controller_test.rb b/test/controllers/auth_tokens_controller_test.rb index 95a0e410..7456ba71 100644 --- a/test/controllers/auth_tokens_controller_test.rb +++ b/test/controllers/auth_tokens_controller_test.rb @@ -63,6 +63,7 @@ class AuthTokensControllerTest < ActionDispatch::IntegrationTest end assert_response :unauthorized + assert_includes response.parsed_body['errors'], { 'attribute' => 'base', 'type' => 'wrong_credentials' } end test 'should not create auth_token without user_agent' do diff --git a/test/controllers/codec_conversions_controller_test.rb b/test/controllers/codec_conversions_controller_test.rb index e68ce2c4..62ee6ad6 100644 --- a/test/controllers/codec_conversions_controller_test.rb +++ b/test/controllers/codec_conversions_controller_test.rb @@ -52,6 +52,7 @@ class CodecConversionsControllerTest < ActionDispatch::IntegrationTest end assert_response :forbidden + assert_includes response.parsed_body['errors'], { 'policy' => 'codec_conversion_policy', 'type' => 'forbidden', 'action' => 'create?' } end test 'should create codec_conversion for moderator' do @@ -164,6 +165,7 @@ class CodecConversionsControllerTest < ActionDispatch::IntegrationTest } } assert_response :forbidden + assert_includes response.parsed_body['errors'], { 'policy' => 'codec_conversion_policy', 'type' => 'forbidden', 'action' => 'update?' } end test 'should update codec_conversion for moderator' do @@ -205,6 +207,7 @@ class CodecConversionsControllerTest < ActionDispatch::IntegrationTest end assert_response :forbidden + assert_includes response.parsed_body['errors'], { 'policy' => 'codec_conversion_policy', 'type' => 'forbidden', 'action' => 'destroy?' } end test 'should destroy codec_conversion for moderator' do diff --git a/test/controllers/codecs_controller_test.rb b/test/controllers/codecs_controller_test.rb index 1769cc59..b636a072 100644 --- a/test/controllers/codecs_controller_test.rb +++ b/test/controllers/codecs_controller_test.rb @@ -40,6 +40,7 @@ class CodecsControllerTest < ActionDispatch::IntegrationTest end assert_response :forbidden + assert_includes response.parsed_body['errors'], { 'policy' => 'codec_policy', 'type' => 'forbidden', 'action' => 'create?' } end test 'should not create codec with missing extension' do @@ -95,6 +96,7 @@ class CodecsControllerTest < ActionDispatch::IntegrationTest patch codec_url(@codec), params: { codec: { mimetype: @codec.mimetype } } assert_response :forbidden + assert_includes response.parsed_body['errors'], { 'policy' => 'codec_policy', 'type' => 'forbidden', 'action' => 'update?' } end test 'should not update codec when clearing mimetype' do @@ -128,6 +130,7 @@ class CodecsControllerTest < ActionDispatch::IntegrationTest end assert_response :forbidden + assert_includes response.parsed_body['errors'], { 'policy' => 'codec_policy', 'type' => 'forbidden', 'action' => 'destroy?' } end test 'should destroy codec for moderator' do diff --git a/test/controllers/cover_filenames_controller_test.rb b/test/controllers/cover_filenames_controller_test.rb index 37fee62d..51da2a42 100644 --- a/test/controllers/cover_filenames_controller_test.rb +++ b/test/controllers/cover_filenames_controller_test.rb @@ -10,6 +10,7 @@ class CoverFilenamesControllerTest < ActionDispatch::IntegrationTest get cover_filenames_url assert_response :forbidden + assert_includes response.parsed_body['errors'], { 'policy' => 'cover_filename_policy', 'type' => 'forbidden', 'action' => 'index?' } end test 'should get index for moderator' do @@ -59,6 +60,7 @@ class CoverFilenamesControllerTest < ActionDispatch::IntegrationTest end assert_response :forbidden + assert_includes response.parsed_body['errors'], { 'policy' => 'cover_filename_policy', 'type' => 'forbidden', 'action' => 'create?' } end test 'should not create cover_filename with empty filename' do @@ -95,6 +97,7 @@ class CoverFilenamesControllerTest < ActionDispatch::IntegrationTest get cover_filename_url(@cover_filename) assert_response :forbidden + assert_includes response.parsed_body['errors'], { 'policy' => 'cover_filename_policy', 'type' => 'forbidden', 'action' => 'show?' } end test 'should show cover_filename for moderator' do @@ -117,6 +120,7 @@ class CoverFilenamesControllerTest < ActionDispatch::IntegrationTest end assert_response :forbidden + assert_includes response.parsed_body['errors'], { 'policy' => 'cover_filename_policy', 'type' => 'forbidden', 'action' => 'destroy?' } end test 'should destroy cover_filename for moderator' do diff --git a/test/controllers/genres_controller_test.rb b/test/controllers/genres_controller_test.rb index ee744108..09be283d 100644 --- a/test/controllers/genres_controller_test.rb +++ b/test/controllers/genres_controller_test.rb @@ -40,6 +40,7 @@ class GenresControllerTest < ActionDispatch::IntegrationTest end assert_response :forbidden + assert_includes response.parsed_body['errors'], { 'policy' => 'genre_policy', 'type' => 'forbidden', 'action' => 'create?' } end test 'should not create genre with empty name' do @@ -82,6 +83,7 @@ class GenresControllerTest < ActionDispatch::IntegrationTest patch genre_url(@genre), params: { genre: { name: @genre.name } } assert_response :forbidden + assert_includes response.parsed_body['errors'], { 'policy' => 'genre_policy', 'type' => 'forbidden', 'action' => 'update?' } end test 'should not update genre to empty name' do @@ -113,6 +115,7 @@ class GenresControllerTest < ActionDispatch::IntegrationTest end assert_response :forbidden + assert_includes response.parsed_body['errors'], { 'policy' => 'genre_policy', 'type' => 'forbidden', 'action' => 'destroy?' } end test 'should destroy genre for moderator' do @@ -139,6 +142,7 @@ class GenresControllerTest < ActionDispatch::IntegrationTest end assert_response :forbidden + assert_includes response.parsed_body['errors'], { 'policy' => 'genre_policy', 'type' => 'forbidden', 'action' => 'destroy_empty?' } end test 'should destroy empty genres for moderator' do @@ -179,6 +183,7 @@ class GenresControllerTest < ActionDispatch::IntegrationTest end assert_response :forbidden + assert_includes response.parsed_body['errors'], { 'policy' => 'genre_policy', 'type' => 'forbidden', 'action' => 'merge?' } end test 'should merge genres for moderator' do diff --git a/test/controllers/image_types_controller_test.rb b/test/controllers/image_types_controller_test.rb index 098ec72b..430bdb48 100644 --- a/test/controllers/image_types_controller_test.rb +++ b/test/controllers/image_types_controller_test.rb @@ -40,6 +40,7 @@ class ImageTypesControllerTest < ActionDispatch::IntegrationTest end assert_response :forbidden + assert_includes response.parsed_body['errors'], { 'policy' => 'image_type_policy', 'type' => 'forbidden', 'action' => 'create?' } end test 'should not create image_type without extension' do @@ -94,6 +95,7 @@ class ImageTypesControllerTest < ActionDispatch::IntegrationTest patch image_type_url(@image_type), params: { image_type: { mimetype: @image_type.mimetype } } assert_response :forbidden + assert_includes response.parsed_body['errors'], { 'policy' => 'image_type_policy', 'type' => 'forbidden', 'action' => 'update?' } end test 'should not update image_type to empty mimetype' do @@ -125,6 +127,7 @@ class ImageTypesControllerTest < ActionDispatch::IntegrationTest end assert_response :forbidden + assert_includes response.parsed_body['errors'], { 'policy' => 'image_type_policy', 'type' => 'forbidden', 'action' => 'destroy?' } end test 'should destroy image_type for moderator' do diff --git a/test/controllers/labels_controller_test.rb b/test/controllers/labels_controller_test.rb index 87280c96..b95e5d83 100644 --- a/test/controllers/labels_controller_test.rb +++ b/test/controllers/labels_controller_test.rb @@ -40,6 +40,7 @@ class LabelsControllerTest < ActionDispatch::IntegrationTest end assert_response :forbidden + assert_includes response.parsed_body['errors'], { 'policy' => 'label_policy', 'type' => 'forbidden', 'action' => 'create?' } end test 'should not create label with empty name' do @@ -83,6 +84,7 @@ class LabelsControllerTest < ActionDispatch::IntegrationTest patch label_url(@label), params: { label: { name: @label.name } } assert_response :forbidden + assert_includes response.parsed_body['errors'], { 'policy' => 'label_policy', 'type' => 'forbidden', 'action' => 'update?' } end test 'should not update label to empty name' do @@ -114,6 +116,7 @@ class LabelsControllerTest < ActionDispatch::IntegrationTest end assert_response :forbidden + assert_includes response.parsed_body['errors'], { 'policy' => 'label_policy', 'type' => 'forbidden', 'action' => 'destroy?' } end test 'should destroy label for moderator' do @@ -140,6 +143,7 @@ class LabelsControllerTest < ActionDispatch::IntegrationTest end assert_response :forbidden + assert_includes response.parsed_body['errors'], { 'policy' => 'label_policy', 'type' => 'forbidden', 'action' => 'destroy_empty?' } end test 'should destroy empty labels for moderator' do @@ -180,6 +184,7 @@ class LabelsControllerTest < ActionDispatch::IntegrationTest end assert_response :forbidden + assert_includes response.parsed_body['errors'], { 'policy' => 'label_policy', 'type' => 'forbidden', 'action' => 'merge?' } end test 'should merge labels for moderator' do diff --git a/test/controllers/locations_controller_test.rb b/test/controllers/locations_controller_test.rb index b1e86d72..c1c8dd65 100644 --- a/test/controllers/locations_controller_test.rb +++ b/test/controllers/locations_controller_test.rb @@ -10,6 +10,7 @@ class LocationsControllerTest < ActionDispatch::IntegrationTest get locations_url assert_response :forbidden + assert_includes response.parsed_body['errors'], { 'policy' => 'location_policy', 'type' => 'forbidden', 'action' => 'index?' } end test 'should get index for moderator' do @@ -59,6 +60,7 @@ class LocationsControllerTest < ActionDispatch::IntegrationTest end assert_response :forbidden + assert_includes response.parsed_body['errors'], { 'policy' => 'location_policy', 'type' => 'forbidden', 'action' => 'create?' } end test 'should not create location with empty path' do @@ -95,6 +97,7 @@ class LocationsControllerTest < ActionDispatch::IntegrationTest get location_url(@location) assert_response :forbidden + assert_includes response.parsed_body['errors'], { 'policy' => 'location_policy', 'type' => 'forbidden', 'action' => 'show?' } end test 'should show location for moderator' do @@ -117,6 +120,7 @@ class LocationsControllerTest < ActionDispatch::IntegrationTest end assert_response :forbidden + assert_includes response.parsed_body['errors'], { 'policy' => 'location_policy', 'type' => 'forbidden', 'action' => 'destroy?' } end test 'should destroy location for moderator' do diff --git a/test/controllers/playlists_controller_test.rb b/test/controllers/playlists_controller_test.rb index aec8011e..f0576c9b 100644 --- a/test/controllers/playlists_controller_test.rb +++ b/test/controllers/playlists_controller_test.rb @@ -125,6 +125,7 @@ class PlaylistsControllerTest < ActionDispatch::IntegrationTest patch playlist_url(@playlist), params: { playlist: { name: 'My playlist' } } assert_response :forbidden + assert_includes response.parsed_body['errors'], { 'policy' => 'playlist_policy', 'type' => 'forbidden', 'action' => 'update?' } end test 'should not update secret playlist for different user' do @@ -133,6 +134,7 @@ class PlaylistsControllerTest < ActionDispatch::IntegrationTest patch playlist_url(@playlist), params: { playlist: { name: 'My playlist' } } assert_response :forbidden + assert_includes response.parsed_body['errors'], { 'policy' => 'playlist_policy', 'type' => 'forbidden', 'action' => 'update?' } end test 'should destroy shared playlist for user' do @@ -151,6 +153,7 @@ class PlaylistsControllerTest < ActionDispatch::IntegrationTest end assert_response :forbidden + assert_includes response.parsed_body['errors'], { 'policy' => 'playlist_policy', 'type' => 'forbidden', 'action' => 'destroy?' } end test 'should not destroy secret playlist for different user' do @@ -161,6 +164,7 @@ class PlaylistsControllerTest < ActionDispatch::IntegrationTest end assert_response :forbidden + assert_includes response.parsed_body['errors'], { 'policy' => 'playlist_policy', 'type' => 'forbidden', 'action' => 'destroy?' } end test 'should not add item if no user' do @@ -173,6 +177,7 @@ class PlaylistsControllerTest < ActionDispatch::IntegrationTest end assert_response :unauthorized + assert_includes response.parsed_body['errors'], { 'policy' => 'playlist_policy', 'type' => 'unauthorized', 'action' => 'add_item?' } end test 'should add item in shared playlist' do @@ -208,5 +213,6 @@ class PlaylistsControllerTest < ActionDispatch::IntegrationTest end assert_response :forbidden + assert_includes response.parsed_body['errors'], { 'policy' => 'playlist_policy', 'type' => 'forbidden', 'action' => 'add_item?' } end end diff --git a/test/controllers/rescans_controller_test.rb b/test/controllers/rescans_controller_test.rb index 50d21ca8..9a6688dd 100644 --- a/test/controllers/rescans_controller_test.rb +++ b/test/controllers/rescans_controller_test.rb @@ -12,6 +12,7 @@ class RescansControllerTest < ActionDispatch::IntegrationTest get rescans_url assert_response :forbidden + assert_includes response.parsed_body['errors'], { 'policy' => 'rescan_runner_policy', 'type' => 'forbidden', 'action' => 'index?' } end test 'should get index for moderator' do @@ -58,6 +59,7 @@ class RescansControllerTest < ActionDispatch::IntegrationTest get rescan_url(@runner) assert_response :forbidden + assert_includes response.parsed_body['errors'], { 'policy' => 'rescan_runner_policy', 'type' => 'forbidden', 'action' => 'show?' } end test 'should get show for moderator' do @@ -71,6 +73,7 @@ class RescansControllerTest < ActionDispatch::IntegrationTest post rescan_url(@runner) assert_response :forbidden + assert_includes response.parsed_body['errors'], { 'policy' => 'rescan_runner_policy', 'type' => 'forbidden', 'action' => 'start?' } end test 'should start rescan' do @@ -91,6 +94,7 @@ class RescansControllerTest < ActionDispatch::IntegrationTest post rescans_url assert_response :forbidden + assert_includes response.parsed_body['errors'], { 'policy' => 'rescan_runner_policy', 'type' => 'forbidden', 'action' => 'start_all?' } end test 'should start all rescans' do diff --git a/test/controllers/tracks_controller_test.rb b/test/controllers/tracks_controller_test.rb index 0d90a7f2..d131c8ca 100644 --- a/test/controllers/tracks_controller_test.rb +++ b/test/controllers/tracks_controller_test.rb @@ -63,6 +63,7 @@ class TracksControllerTest < ActionDispatch::IntegrationTest end assert_response :forbidden + assert_includes response.parsed_body['errors'], { 'policy' => 'track_policy', 'type' => 'forbidden', 'action' => 'create?' } end test 'should not create track without title' do @@ -188,6 +189,7 @@ class TracksControllerTest < ActionDispatch::IntegrationTest end assert_response :forbidden + assert_includes response.parsed_body['errors'], { 'policy' => 'track_policy', 'type' => 'forbidden', 'action' => 'destroy?' } end test 'should destroy track for moderator' do @@ -206,6 +208,7 @@ class TracksControllerTest < ActionDispatch::IntegrationTest end assert_response :forbidden + assert_includes response.parsed_body['errors'], { 'policy' => 'track_policy', 'type' => 'forbidden', 'action' => 'destroy_empty?' } end test 'should destroy empty tracks for moderator' do @@ -232,6 +235,7 @@ class TracksControllerTest < ActionDispatch::IntegrationTest end assert_response :forbidden + assert_includes response.parsed_body['errors'], { 'policy' => 'track_policy', 'type' => 'forbidden', 'action' => 'merge?' } end test 'should merge tracks for moderator' do @@ -257,6 +261,7 @@ class TracksControllerTest < ActionDispatch::IntegrationTest get audio_track_url(create(:track)) assert_response :not_found + assert_includes response.parsed_body['errors'], { 'model' => 'audio', 'type' => 'not_found' } end test 'should return not_found and destroy audio if file is missing ' do @@ -267,6 +272,7 @@ class TracksControllerTest < ActionDispatch::IntegrationTest end assert_response :not_found + assert_includes response.parsed_body['errors'], { 'model' => 'audio', 'type' => 'not_found' } end test 'should serve audio to user' do @@ -307,6 +313,7 @@ class TracksControllerAudioTest < ActionDispatch::IntegrationTest get audio_track_url(track, codec_conversion_id: 0) assert_response :not_found + assert_includes response.parsed_body['errors'], { 'model' => 'codec_conversion', 'type' => 'not_found' } end test 'should create transcoded_item if codec_conversion is present' do diff --git a/test/controllers/users_controller_test.rb b/test/controllers/users_controller_test.rb index c0c2bcf9..84bb7527 100644 --- a/test/controllers/users_controller_test.rb +++ b/test/controllers/users_controller_test.rb @@ -40,6 +40,7 @@ class UsersControllerTest < ActionDispatch::IntegrationTest end assert_response :forbidden + assert_includes response.parsed_body['errors'], { 'policy' => 'user_policy', 'type' => 'forbidden', 'action' => 'create?' } end test 'should not create user without name' do @@ -79,6 +80,7 @@ class UsersControllerTest < ActionDispatch::IntegrationTest patch user_url(@user), params: { user: { password: 'new password', current_password: 'not correct' } } assert_response :unauthorized + assert_includes response.parsed_body['errors'], { 'attribute' => 'base', 'type' => 'incorrect_password' } end test 'should update password with current password for current user' do From c766af8cdbc24de70af402edce5cf63af8d4324f Mon Sep 17 00:00:00 2001 From: Robbe Van Petegem Date: Sat, 25 Jul 2026 12:08:15 +0200 Subject: [PATCH 3/7] Include `model` key in error messages --- app/controllers/application_controller.rb | 6 +++--- app/controllers/auth_tokens_controller.rb | 2 +- app/controllers/users_controller.rb | 2 +- test/controllers/albums_controller_test.rb | 4 ++-- test/controllers/artists_controller_test.rb | 4 ++-- test/controllers/auth_tokens_controller_test.rb | 4 ++-- test/controllers/codec_conversions_controller_test.rb | 10 +++++----- test/controllers/codecs_controller_test.rb | 6 +++--- test/controllers/cover_filenames_controller_test.rb | 2 +- test/controllers/genres_controller_test.rb | 4 ++-- test/controllers/image_types_controller_test.rb | 6 +++--- test/controllers/labels_controller_test.rb | 4 ++-- test/controllers/locations_controller_test.rb | 2 +- test/controllers/playlists_controller_test.rb | 4 ++-- test/controllers/plays_controller_test.rb | 2 +- test/controllers/tracks_controller_test.rb | 6 +++--- test/controllers/users_controller_test.rb | 6 +++--- 17 files changed, 37 insertions(+), 37 deletions(-) diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 99a0a963..6dbf7eaa 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -45,13 +45,13 @@ def stale?(scope:, **) super(etag:, **) end - def transform_error_for_json(error) - { attribute: error.attribute, type: ERROR_TYPE_MAP[error.type] } + def transform_error_for_json(object, error) + { model: object.model_name.singular, attribute: error.attribute, type: ERROR_TYPE_MAP[error.type] } end # This method expects an instance of a class that includes `ActiveModel::Errors` def transform_errors_for_json(object) - { errors: object.errors.errors.map { transform_error_for_json(it) } } + { errors: object.errors.errors.map { transform_error_for_json(object, it) } } end private diff --git a/app/controllers/auth_tokens_controller.rb b/app/controllers/auth_tokens_controller.rb index f646ec8d..b5b2aef7 100644 --- a/app/controllers/auth_tokens_controller.rb +++ b/app/controllers/auth_tokens_controller.rb @@ -19,7 +19,7 @@ def create user = User.find_by(name: params[:name]) unless user.try(:authenticate, params[:password]) - render json: { errors: [{ attribute: :base, type: :wrong_credentials }] }, status: :unauthorized + render json: { errors: [{ model: :auth_token, attribute: :base, type: :wrong_credentials }] }, status: :unauthorized return end diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index df5efbdd..4b9822e3 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -29,7 +29,7 @@ def update if @user == current_user && params[:user][:password].present? && !@user.try(:authenticate, params[:user][:current_password]) - render json: { errors: [{ attribute: :base, type: :incorrect_password }] }, status: :unauthorized + render json: { errors: [{ model: :user, attribute: :base, type: :incorrect_password }] }, status: :unauthorized return end diff --git a/test/controllers/albums_controller_test.rb b/test/controllers/albums_controller_test.rb index 8c4b94eb..b50ff190 100644 --- a/test/controllers/albums_controller_test.rb +++ b/test/controllers/albums_controller_test.rb @@ -75,7 +75,7 @@ class AlbumsControllerTest < ActionDispatch::IntegrationTest end assert_response :unprocessable_content - assert_includes response.parsed_body['errors'], { 'attribute' => 'title', 'type' => 'required' } + assert_includes response.parsed_body['errors'], { 'model' => 'album', 'attribute' => 'title', 'type' => 'required' } end test 'should create dependent album_labels' do @@ -156,7 +156,7 @@ class AlbumsControllerTest < ActionDispatch::IntegrationTest assert_response :unprocessable_content assert_not_equal '', @album.reload.title - assert_includes response.parsed_body['errors'], { 'attribute' => 'title', 'type' => 'required' } + assert_includes response.parsed_body['errors'], { 'model' => 'album', 'attribute' => 'title', 'type' => 'required' } end test 'should clear review comment' do diff --git a/test/controllers/artists_controller_test.rb b/test/controllers/artists_controller_test.rb index 7eb3c270..fe5b12e8 100644 --- a/test/controllers/artists_controller_test.rb +++ b/test/controllers/artists_controller_test.rb @@ -70,7 +70,7 @@ class ArtistsControllerTest < ActionDispatch::IntegrationTest end assert_response :unprocessable_content - assert_includes response.parsed_body['errors'], { 'attribute' => 'name', 'type' => 'required' } + assert_includes response.parsed_body['errors'], { 'model' => 'artist', 'attribute' => 'name', 'type' => 'required' } end test 'should show artist' do @@ -94,7 +94,7 @@ class ArtistsControllerTest < ActionDispatch::IntegrationTest assert_response :unprocessable_content assert_not_equal '', @artist.reload.name - assert_includes response.parsed_body['errors'], { 'attribute' => 'name', 'type' => 'required' } + assert_includes response.parsed_body['errors'], { 'model' => 'artist', 'attribute' => 'name', 'type' => 'required' } end test 'should not update artist metadata for user' do diff --git a/test/controllers/auth_tokens_controller_test.rb b/test/controllers/auth_tokens_controller_test.rb index 7456ba71..79698a84 100644 --- a/test/controllers/auth_tokens_controller_test.rb +++ b/test/controllers/auth_tokens_controller_test.rb @@ -63,7 +63,7 @@ class AuthTokensControllerTest < ActionDispatch::IntegrationTest end assert_response :unauthorized - assert_includes response.parsed_body['errors'], { 'attribute' => 'base', 'type' => 'wrong_credentials' } + assert_includes response.parsed_body['errors'], { 'model' => 'auth_token', 'attribute' => 'base', 'type' => 'wrong_credentials' } end test 'should not create auth_token without user_agent' do @@ -75,7 +75,7 @@ class AuthTokensControllerTest < ActionDispatch::IntegrationTest end assert_response :unprocessable_content - assert_includes response.parsed_body['errors'], { 'attribute' => 'user_agent', 'type' => 'required' } + assert_includes response.parsed_body['errors'], { 'model' => 'auth_token', 'attribute' => 'user_agent', 'type' => 'required' } end test 'should show auth_token' do diff --git a/test/controllers/codec_conversions_controller_test.rb b/test/controllers/codec_conversions_controller_test.rb index 62ee6ad6..c3f76960 100644 --- a/test/controllers/codec_conversions_controller_test.rb +++ b/test/controllers/codec_conversions_controller_test.rb @@ -91,7 +91,7 @@ class CodecConversionsControllerTest < ActionDispatch::IntegrationTest end assert_response :unprocessable_content - assert_includes response.parsed_body['errors'], { 'attribute' => 'name', 'type' => 'not_unique' } + assert_includes response.parsed_body['errors'], { 'model' => 'codec_conversion', 'attribute' => 'name', 'type' => 'not_unique' } end test 'should not create codec_conversion with empty ffmpeg_params' do @@ -105,7 +105,7 @@ class CodecConversionsControllerTest < ActionDispatch::IntegrationTest end assert_response :unprocessable_content - assert_includes response.parsed_body['errors'], { 'attribute' => 'ffmpeg_params', 'type' => 'required' } + assert_includes response.parsed_body['errors'], { 'model' => 'codec_conversion', 'attribute' => 'ffmpeg_params', 'type' => 'required' } end test 'should not create codec_conversion with empty name' do @@ -119,7 +119,7 @@ class CodecConversionsControllerTest < ActionDispatch::IntegrationTest end assert_response :unprocessable_content - assert_includes response.parsed_body['errors'], { 'attribute' => 'name', 'type' => 'required' } + assert_includes response.parsed_body['errors'], { 'model' => 'codec_conversion', 'attribute' => 'name', 'type' => 'required' } end test 'should not create codec_conversion with non-existing resulting_codec' do @@ -134,7 +134,7 @@ class CodecConversionsControllerTest < ActionDispatch::IntegrationTest end assert_response :unprocessable_content - assert_includes response.parsed_body['errors'], { 'attribute' => 'resulting_codec', 'type' => 'required' } + assert_includes response.parsed_body['errors'], { 'model' => 'codec_conversion', 'attribute' => 'resulting_codec', 'type' => 'required' } end test 'should create codec_conversion for admin' do @@ -187,7 +187,7 @@ class CodecConversionsControllerTest < ActionDispatch::IntegrationTest end assert_response :unprocessable_content - assert_includes response.parsed_body['errors'], { 'attribute' => 'name', 'type' => 'required' } + assert_includes response.parsed_body['errors'], { 'model' => 'codec_conversion', 'attribute' => 'name', 'type' => 'required' } end test 'should update codec_conversion for admin' do diff --git a/test/controllers/codecs_controller_test.rb b/test/controllers/codecs_controller_test.rb index b636a072..694955b4 100644 --- a/test/controllers/codecs_controller_test.rb +++ b/test/controllers/codecs_controller_test.rb @@ -51,7 +51,7 @@ class CodecsControllerTest < ActionDispatch::IntegrationTest end assert_response :unprocessable_content - assert_includes response.parsed_body['errors'], { 'attribute' => 'extension', 'type' => 'required' } + assert_includes response.parsed_body['errors'], { 'model' => 'codec', 'attribute' => 'extension', 'type' => 'required' } end test 'should not create codec with missing mimetype' do @@ -63,7 +63,7 @@ class CodecsControllerTest < ActionDispatch::IntegrationTest end assert_response :unprocessable_content - assert_includes response.parsed_body['errors'], { 'attribute' => 'mimetype', 'type' => 'required' } + assert_includes response.parsed_body['errors'], { 'model' => 'codec', 'attribute' => 'mimetype', 'type' => 'required' } end test 'should create codec for moderator' do @@ -107,7 +107,7 @@ class CodecsControllerTest < ActionDispatch::IntegrationTest end assert_response :unprocessable_content - assert_includes response.parsed_body['errors'], { 'attribute' => 'mimetype', 'type' => 'required' } + assert_includes response.parsed_body['errors'], { 'model' => 'codec', 'attribute' => 'mimetype', 'type' => 'required' } end test 'should update codec for moderator' do diff --git a/test/controllers/cover_filenames_controller_test.rb b/test/controllers/cover_filenames_controller_test.rb index 51da2a42..535a488d 100644 --- a/test/controllers/cover_filenames_controller_test.rb +++ b/test/controllers/cover_filenames_controller_test.rb @@ -70,7 +70,7 @@ class CoverFilenamesControllerTest < ActionDispatch::IntegrationTest end assert_response :unprocessable_content - assert_includes response.parsed_body['errors'], { 'attribute' => 'filename', 'type' => 'required' } + assert_includes response.parsed_body['errors'], { 'model' => 'cover_filename', 'attribute' => 'filename', 'type' => 'required' } end test 'should create cover_filename for moderator' do diff --git a/test/controllers/genres_controller_test.rb b/test/controllers/genres_controller_test.rb index 09be283d..f5ec82ef 100644 --- a/test/controllers/genres_controller_test.rb +++ b/test/controllers/genres_controller_test.rb @@ -50,7 +50,7 @@ class GenresControllerTest < ActionDispatch::IntegrationTest end assert_response :unprocessable_content - assert_includes response.parsed_body['errors'], { 'attribute' => 'name', 'type' => 'required' } + assert_includes response.parsed_body['errors'], { 'model' => 'genre', 'attribute' => 'name', 'type' => 'required' } end test 'should create genre for moderator' do @@ -92,7 +92,7 @@ class GenresControllerTest < ActionDispatch::IntegrationTest assert_response :unprocessable_content assert_not_equal '', @genre.reload.name - assert_includes response.parsed_body['errors'], { 'attribute' => 'name', 'type' => 'required' } + assert_includes response.parsed_body['errors'], { 'model' => 'genre', 'attribute' => 'name', 'type' => 'required' } end test 'should update genre for moderator' do diff --git a/test/controllers/image_types_controller_test.rb b/test/controllers/image_types_controller_test.rb index 430bdb48..87f196bf 100644 --- a/test/controllers/image_types_controller_test.rb +++ b/test/controllers/image_types_controller_test.rb @@ -51,7 +51,7 @@ class ImageTypesControllerTest < ActionDispatch::IntegrationTest end assert_response :unprocessable_content - assert_includes response.parsed_body['errors'], { 'attribute' => 'extension', 'type' => 'required' } + assert_includes response.parsed_body['errors'], { 'model' => 'image_type', 'attribute' => 'extension', 'type' => 'required' } end test 'should not create image_type without mimetype' do @@ -62,7 +62,7 @@ class ImageTypesControllerTest < ActionDispatch::IntegrationTest end assert_response :unprocessable_content - assert_includes response.parsed_body['errors'], { 'attribute' => 'mimetype', 'type' => 'required' } + assert_includes response.parsed_body['errors'], { 'model' => 'image_type', 'attribute' => 'mimetype', 'type' => 'required' } end test 'should create image_type for moderator' do @@ -104,7 +104,7 @@ class ImageTypesControllerTest < ActionDispatch::IntegrationTest assert_response :unprocessable_content assert_not_equal '', @image_type.reload.mimetype - assert_includes response.parsed_body['errors'], { 'attribute' => 'mimetype', 'type' => 'required' } + assert_includes response.parsed_body['errors'], { 'model' => 'image_type', 'attribute' => 'mimetype', 'type' => 'required' } end test 'should update image_type for moderator' do diff --git a/test/controllers/labels_controller_test.rb b/test/controllers/labels_controller_test.rb index b95e5d83..3e26cc97 100644 --- a/test/controllers/labels_controller_test.rb +++ b/test/controllers/labels_controller_test.rb @@ -51,7 +51,7 @@ class LabelsControllerTest < ActionDispatch::IntegrationTest end assert_response :unprocessable_content - assert_includes response.parsed_body['errors'], { 'attribute' => 'name', 'type' => 'required' } + assert_includes response.parsed_body['errors'], { 'model' => 'label', 'attribute' => 'name', 'type' => 'required' } end test 'should create label for moderator' do @@ -93,7 +93,7 @@ class LabelsControllerTest < ActionDispatch::IntegrationTest assert_response :unprocessable_content assert_not_equal '', @label.reload.name - assert_includes response.parsed_body['errors'], { 'attribute' => 'name', 'type' => 'required' } + assert_includes response.parsed_body['errors'], { 'model' => 'label', 'attribute' => 'name', 'type' => 'required' } end test 'should update label for moderator' do diff --git a/test/controllers/locations_controller_test.rb b/test/controllers/locations_controller_test.rb index c1c8dd65..8f7c1d6d 100644 --- a/test/controllers/locations_controller_test.rb +++ b/test/controllers/locations_controller_test.rb @@ -70,7 +70,7 @@ class LocationsControllerTest < ActionDispatch::IntegrationTest end assert_response :unprocessable_content - assert_includes response.parsed_body['errors'], { 'attribute' => 'path', 'type' => 'required' } + assert_includes response.parsed_body['errors'], { 'model' => 'location', 'attribute' => 'path', 'type' => 'required' } end test 'should create location for moderator' do diff --git a/test/controllers/playlists_controller_test.rb b/test/controllers/playlists_controller_test.rb index f0576c9b..ae148103 100644 --- a/test/controllers/playlists_controller_test.rb +++ b/test/controllers/playlists_controller_test.rb @@ -66,7 +66,7 @@ class PlaylistsControllerTest < ActionDispatch::IntegrationTest end assert_response :unprocessable_content - assert_includes response.parsed_body['errors'], { 'attribute' => 'name', 'type' => 'required' } + assert_includes response.parsed_body['errors'], { 'model' => 'playlist', 'attribute' => 'name', 'type' => 'required' } end test 'should create personal playlist for current user if specified' do @@ -103,7 +103,7 @@ class PlaylistsControllerTest < ActionDispatch::IntegrationTest end assert_response :unprocessable_content - assert_includes response.parsed_body['errors'], { 'attribute' => 'name', 'type' => 'required' } + assert_includes response.parsed_body['errors'], { 'model' => 'playlist', 'attribute' => 'name', 'type' => 'required' } end test 'should create playlist items during update' do diff --git a/test/controllers/plays_controller_test.rb b/test/controllers/plays_controller_test.rb index 93f90827..9e70c240 100644 --- a/test/controllers/plays_controller_test.rb +++ b/test/controllers/plays_controller_test.rb @@ -75,7 +75,7 @@ class PlaysControllerTest < ActionDispatch::IntegrationTest end assert_response :unprocessable_content - assert_includes response.parsed_body['errors'], { 'attribute' => 'played_at', 'type' => 'required' } + assert_includes response.parsed_body['errors'], { 'model' => 'play', 'attribute' => 'played_at', 'type' => 'required' } end test 'should get stats and not return play stats for other users' do diff --git a/test/controllers/tracks_controller_test.rb b/test/controllers/tracks_controller_test.rb index d131c8ca..8a6f4b0b 100644 --- a/test/controllers/tracks_controller_test.rb +++ b/test/controllers/tracks_controller_test.rb @@ -73,7 +73,7 @@ class TracksControllerTest < ActionDispatch::IntegrationTest end assert_response :unprocessable_content - assert_includes response.parsed_body['errors'], { 'attribute' => 'title', 'type' => 'required' } + assert_includes response.parsed_body['errors'], { 'model' => 'track', 'attribute' => 'title', 'type' => 'required' } end test 'should not create track without album_id' do @@ -83,7 +83,7 @@ class TracksControllerTest < ActionDispatch::IntegrationTest end assert_response :unprocessable_content - assert_includes response.parsed_body['errors'], { 'attribute' => 'album', 'type' => 'required' } + assert_includes response.parsed_body['errors'], { 'model' => 'track', 'attribute' => 'album', 'type' => 'required' } end test 'should create track for moderator' do @@ -160,7 +160,7 @@ class TracksControllerTest < ActionDispatch::IntegrationTest assert_response :unprocessable_content assert_not_equal '', @track.reload.title - assert_includes response.parsed_body['errors'], { 'attribute' => 'title', 'type' => 'required' } + assert_includes response.parsed_body['errors'], { 'model' => 'track', 'attribute' => 'title', 'type' => 'required' } end test 'should clear review comment' do diff --git a/test/controllers/users_controller_test.rb b/test/controllers/users_controller_test.rb index 84bb7527..4cae79f3 100644 --- a/test/controllers/users_controller_test.rb +++ b/test/controllers/users_controller_test.rb @@ -51,7 +51,7 @@ class UsersControllerTest < ActionDispatch::IntegrationTest end assert_response :unprocessable_content - assert_includes response.parsed_body['errors'], { 'attribute' => 'name', 'type' => 'required' } + assert_includes response.parsed_body['errors'], { 'model' => 'user', 'attribute' => 'name', 'type' => 'required' } end test 'should create user for admin' do @@ -80,7 +80,7 @@ class UsersControllerTest < ActionDispatch::IntegrationTest patch user_url(@user), params: { user: { password: 'new password', current_password: 'not correct' } } assert_response :unauthorized - assert_includes response.parsed_body['errors'], { 'attribute' => 'base', 'type' => 'incorrect_password' } + assert_includes response.parsed_body['errors'], { 'model' => 'user', 'attribute' => 'base', 'type' => 'incorrect_password' } end test 'should update password with current password for current user' do @@ -102,7 +102,7 @@ class UsersControllerTest < ActionDispatch::IntegrationTest assert_response :unprocessable_content assert_not_equal '', @user.reload.name - assert_includes response.parsed_body['errors'], { 'attribute' => 'name', 'type' => 'required' } + assert_includes response.parsed_body['errors'], { 'model' => 'user', 'attribute' => 'name', 'type' => 'required' } end test 'should not update own permission if not admin' do From ac6a0e5045e4d476e36f0c3595391831ad3ea261 Mon Sep 17 00:00:00 2001 From: Robbe Van Petegem Date: Sat, 25 Jul 2026 12:09:05 +0200 Subject: [PATCH 4/7] Rename error types to make conversion in web easier --- app/controllers/application_controller.rb | 2 +- test/controllers/albums_controller_test.rb | 4 ++-- test/controllers/artists_controller_test.rb | 4 ++-- test/controllers/auth_tokens_controller_test.rb | 2 +- test/controllers/codec_conversions_controller_test.rb | 10 +++++----- test/controllers/codecs_controller_test.rb | 6 +++--- test/controllers/cover_filenames_controller_test.rb | 2 +- test/controllers/genres_controller_test.rb | 4 ++-- test/controllers/image_types_controller_test.rb | 6 +++--- test/controllers/labels_controller_test.rb | 4 ++-- test/controllers/locations_controller_test.rb | 2 +- test/controllers/playlists_controller_test.rb | 4 ++-- test/controllers/plays_controller_test.rb | 2 +- test/controllers/tracks_controller_test.rb | 6 +++--- test/controllers/users_controller_test.rb | 4 ++-- 15 files changed, 31 insertions(+), 31 deletions(-) diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 6dbf7eaa..53b2e841 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -3,7 +3,7 @@ class ApplicationController < ActionController::API include ActionController::HttpAuthentication::Token::ControllerMethods # This map only includes the type of validation errors that we could have inside the app - ERROR_TYPE_MAP = { blank: :required, taken: :not_unique }.freeze + ERROR_TYPE_MAP = { blank: :blank, taken: :taken }.freeze etag { params[:page] } etag { params[:per_page] } diff --git a/test/controllers/albums_controller_test.rb b/test/controllers/albums_controller_test.rb index b50ff190..4bba4c89 100644 --- a/test/controllers/albums_controller_test.rb +++ b/test/controllers/albums_controller_test.rb @@ -75,7 +75,7 @@ class AlbumsControllerTest < ActionDispatch::IntegrationTest end assert_response :unprocessable_content - assert_includes response.parsed_body['errors'], { 'model' => 'album', 'attribute' => 'title', 'type' => 'required' } + assert_includes response.parsed_body['errors'], { 'model' => 'album', 'attribute' => 'title', 'type' => 'blank' } end test 'should create dependent album_labels' do @@ -156,7 +156,7 @@ class AlbumsControllerTest < ActionDispatch::IntegrationTest assert_response :unprocessable_content assert_not_equal '', @album.reload.title - assert_includes response.parsed_body['errors'], { 'model' => 'album', 'attribute' => 'title', 'type' => 'required' } + assert_includes response.parsed_body['errors'], { 'model' => 'album', 'attribute' => 'title', 'type' => 'blank' } end test 'should clear review comment' do diff --git a/test/controllers/artists_controller_test.rb b/test/controllers/artists_controller_test.rb index fe5b12e8..f9107e24 100644 --- a/test/controllers/artists_controller_test.rb +++ b/test/controllers/artists_controller_test.rb @@ -70,7 +70,7 @@ class ArtistsControllerTest < ActionDispatch::IntegrationTest end assert_response :unprocessable_content - assert_includes response.parsed_body['errors'], { 'model' => 'artist', 'attribute' => 'name', 'type' => 'required' } + assert_includes response.parsed_body['errors'], { 'model' => 'artist', 'attribute' => 'name', 'type' => 'blank' } end test 'should show artist' do @@ -94,7 +94,7 @@ class ArtistsControllerTest < ActionDispatch::IntegrationTest assert_response :unprocessable_content assert_not_equal '', @artist.reload.name - assert_includes response.parsed_body['errors'], { 'model' => 'artist', 'attribute' => 'name', 'type' => 'required' } + assert_includes response.parsed_body['errors'], { 'model' => 'artist', 'attribute' => 'name', 'type' => 'blank' } end test 'should not update artist metadata for user' do diff --git a/test/controllers/auth_tokens_controller_test.rb b/test/controllers/auth_tokens_controller_test.rb index 79698a84..af68615a 100644 --- a/test/controllers/auth_tokens_controller_test.rb +++ b/test/controllers/auth_tokens_controller_test.rb @@ -75,7 +75,7 @@ class AuthTokensControllerTest < ActionDispatch::IntegrationTest end assert_response :unprocessable_content - assert_includes response.parsed_body['errors'], { 'model' => 'auth_token', 'attribute' => 'user_agent', 'type' => 'required' } + assert_includes response.parsed_body['errors'], { 'model' => 'auth_token', 'attribute' => 'user_agent', 'type' => 'blank' } end test 'should show auth_token' do diff --git a/test/controllers/codec_conversions_controller_test.rb b/test/controllers/codec_conversions_controller_test.rb index c3f76960..12dfeec0 100644 --- a/test/controllers/codec_conversions_controller_test.rb +++ b/test/controllers/codec_conversions_controller_test.rb @@ -91,7 +91,7 @@ class CodecConversionsControllerTest < ActionDispatch::IntegrationTest end assert_response :unprocessable_content - assert_includes response.parsed_body['errors'], { 'model' => 'codec_conversion', 'attribute' => 'name', 'type' => 'not_unique' } + assert_includes response.parsed_body['errors'], { 'model' => 'codec_conversion', 'attribute' => 'name', 'type' => 'taken' } end test 'should not create codec_conversion with empty ffmpeg_params' do @@ -105,7 +105,7 @@ class CodecConversionsControllerTest < ActionDispatch::IntegrationTest end assert_response :unprocessable_content - assert_includes response.parsed_body['errors'], { 'model' => 'codec_conversion', 'attribute' => 'ffmpeg_params', 'type' => 'required' } + assert_includes response.parsed_body['errors'], { 'model' => 'codec_conversion', 'attribute' => 'ffmpeg_params', 'type' => 'blank' } end test 'should not create codec_conversion with empty name' do @@ -119,7 +119,7 @@ class CodecConversionsControllerTest < ActionDispatch::IntegrationTest end assert_response :unprocessable_content - assert_includes response.parsed_body['errors'], { 'model' => 'codec_conversion', 'attribute' => 'name', 'type' => 'required' } + assert_includes response.parsed_body['errors'], { 'model' => 'codec_conversion', 'attribute' => 'name', 'type' => 'blank' } end test 'should not create codec_conversion with non-existing resulting_codec' do @@ -134,7 +134,7 @@ class CodecConversionsControllerTest < ActionDispatch::IntegrationTest end assert_response :unprocessable_content - assert_includes response.parsed_body['errors'], { 'model' => 'codec_conversion', 'attribute' => 'resulting_codec', 'type' => 'required' } + assert_includes response.parsed_body['errors'], { 'model' => 'codec_conversion', 'attribute' => 'resulting_codec', 'type' => 'blank' } end test 'should create codec_conversion for admin' do @@ -187,7 +187,7 @@ class CodecConversionsControllerTest < ActionDispatch::IntegrationTest end assert_response :unprocessable_content - assert_includes response.parsed_body['errors'], { 'model' => 'codec_conversion', 'attribute' => 'name', 'type' => 'required' } + assert_includes response.parsed_body['errors'], { 'model' => 'codec_conversion', 'attribute' => 'name', 'type' => 'blank' } end test 'should update codec_conversion for admin' do diff --git a/test/controllers/codecs_controller_test.rb b/test/controllers/codecs_controller_test.rb index 694955b4..ea3d0806 100644 --- a/test/controllers/codecs_controller_test.rb +++ b/test/controllers/codecs_controller_test.rb @@ -51,7 +51,7 @@ class CodecsControllerTest < ActionDispatch::IntegrationTest end assert_response :unprocessable_content - assert_includes response.parsed_body['errors'], { 'model' => 'codec', 'attribute' => 'extension', 'type' => 'required' } + assert_includes response.parsed_body['errors'], { 'model' => 'codec', 'attribute' => 'extension', 'type' => 'blank' } end test 'should not create codec with missing mimetype' do @@ -63,7 +63,7 @@ class CodecsControllerTest < ActionDispatch::IntegrationTest end assert_response :unprocessable_content - assert_includes response.parsed_body['errors'], { 'model' => 'codec', 'attribute' => 'mimetype', 'type' => 'required' } + assert_includes response.parsed_body['errors'], { 'model' => 'codec', 'attribute' => 'mimetype', 'type' => 'blank' } end test 'should create codec for moderator' do @@ -107,7 +107,7 @@ class CodecsControllerTest < ActionDispatch::IntegrationTest end assert_response :unprocessable_content - assert_includes response.parsed_body['errors'], { 'model' => 'codec', 'attribute' => 'mimetype', 'type' => 'required' } + assert_includes response.parsed_body['errors'], { 'model' => 'codec', 'attribute' => 'mimetype', 'type' => 'blank' } end test 'should update codec for moderator' do diff --git a/test/controllers/cover_filenames_controller_test.rb b/test/controllers/cover_filenames_controller_test.rb index 535a488d..8d770236 100644 --- a/test/controllers/cover_filenames_controller_test.rb +++ b/test/controllers/cover_filenames_controller_test.rb @@ -70,7 +70,7 @@ class CoverFilenamesControllerTest < ActionDispatch::IntegrationTest end assert_response :unprocessable_content - assert_includes response.parsed_body['errors'], { 'model' => 'cover_filename', 'attribute' => 'filename', 'type' => 'required' } + assert_includes response.parsed_body['errors'], { 'model' => 'cover_filename', 'attribute' => 'filename', 'type' => 'blank' } end test 'should create cover_filename for moderator' do diff --git a/test/controllers/genres_controller_test.rb b/test/controllers/genres_controller_test.rb index f5ec82ef..c6a71dae 100644 --- a/test/controllers/genres_controller_test.rb +++ b/test/controllers/genres_controller_test.rb @@ -50,7 +50,7 @@ class GenresControllerTest < ActionDispatch::IntegrationTest end assert_response :unprocessable_content - assert_includes response.parsed_body['errors'], { 'model' => 'genre', 'attribute' => 'name', 'type' => 'required' } + assert_includes response.parsed_body['errors'], { 'model' => 'genre', 'attribute' => 'name', 'type' => 'blank' } end test 'should create genre for moderator' do @@ -92,7 +92,7 @@ class GenresControllerTest < ActionDispatch::IntegrationTest assert_response :unprocessable_content assert_not_equal '', @genre.reload.name - assert_includes response.parsed_body['errors'], { 'model' => 'genre', 'attribute' => 'name', 'type' => 'required' } + assert_includes response.parsed_body['errors'], { 'model' => 'genre', 'attribute' => 'name', 'type' => 'blank' } end test 'should update genre for moderator' do diff --git a/test/controllers/image_types_controller_test.rb b/test/controllers/image_types_controller_test.rb index 87f196bf..88b87b23 100644 --- a/test/controllers/image_types_controller_test.rb +++ b/test/controllers/image_types_controller_test.rb @@ -51,7 +51,7 @@ class ImageTypesControllerTest < ActionDispatch::IntegrationTest end assert_response :unprocessable_content - assert_includes response.parsed_body['errors'], { 'model' => 'image_type', 'attribute' => 'extension', 'type' => 'required' } + assert_includes response.parsed_body['errors'], { 'model' => 'image_type', 'attribute' => 'extension', 'type' => 'blank' } end test 'should not create image_type without mimetype' do @@ -62,7 +62,7 @@ class ImageTypesControllerTest < ActionDispatch::IntegrationTest end assert_response :unprocessable_content - assert_includes response.parsed_body['errors'], { 'model' => 'image_type', 'attribute' => 'mimetype', 'type' => 'required' } + assert_includes response.parsed_body['errors'], { 'model' => 'image_type', 'attribute' => 'mimetype', 'type' => 'blank' } end test 'should create image_type for moderator' do @@ -104,7 +104,7 @@ class ImageTypesControllerTest < ActionDispatch::IntegrationTest assert_response :unprocessable_content assert_not_equal '', @image_type.reload.mimetype - assert_includes response.parsed_body['errors'], { 'model' => 'image_type', 'attribute' => 'mimetype', 'type' => 'required' } + assert_includes response.parsed_body['errors'], { 'model' => 'image_type', 'attribute' => 'mimetype', 'type' => 'blank' } end test 'should update image_type for moderator' do diff --git a/test/controllers/labels_controller_test.rb b/test/controllers/labels_controller_test.rb index 3e26cc97..4312c644 100644 --- a/test/controllers/labels_controller_test.rb +++ b/test/controllers/labels_controller_test.rb @@ -51,7 +51,7 @@ class LabelsControllerTest < ActionDispatch::IntegrationTest end assert_response :unprocessable_content - assert_includes response.parsed_body['errors'], { 'model' => 'label', 'attribute' => 'name', 'type' => 'required' } + assert_includes response.parsed_body['errors'], { 'model' => 'label', 'attribute' => 'name', 'type' => 'blank' } end test 'should create label for moderator' do @@ -93,7 +93,7 @@ class LabelsControllerTest < ActionDispatch::IntegrationTest assert_response :unprocessable_content assert_not_equal '', @label.reload.name - assert_includes response.parsed_body['errors'], { 'model' => 'label', 'attribute' => 'name', 'type' => 'required' } + assert_includes response.parsed_body['errors'], { 'model' => 'label', 'attribute' => 'name', 'type' => 'blank' } end test 'should update label for moderator' do diff --git a/test/controllers/locations_controller_test.rb b/test/controllers/locations_controller_test.rb index 8f7c1d6d..1df033d6 100644 --- a/test/controllers/locations_controller_test.rb +++ b/test/controllers/locations_controller_test.rb @@ -70,7 +70,7 @@ class LocationsControllerTest < ActionDispatch::IntegrationTest end assert_response :unprocessable_content - assert_includes response.parsed_body['errors'], { 'model' => 'location', 'attribute' => 'path', 'type' => 'required' } + assert_includes response.parsed_body['errors'], { 'model' => 'location', 'attribute' => 'path', 'type' => 'blank' } end test 'should create location for moderator' do diff --git a/test/controllers/playlists_controller_test.rb b/test/controllers/playlists_controller_test.rb index ae148103..a6806a07 100644 --- a/test/controllers/playlists_controller_test.rb +++ b/test/controllers/playlists_controller_test.rb @@ -66,7 +66,7 @@ class PlaylistsControllerTest < ActionDispatch::IntegrationTest end assert_response :unprocessable_content - assert_includes response.parsed_body['errors'], { 'model' => 'playlist', 'attribute' => 'name', 'type' => 'required' } + assert_includes response.parsed_body['errors'], { 'model' => 'playlist', 'attribute' => 'name', 'type' => 'blank' } end test 'should create personal playlist for current user if specified' do @@ -103,7 +103,7 @@ class PlaylistsControllerTest < ActionDispatch::IntegrationTest end assert_response :unprocessable_content - assert_includes response.parsed_body['errors'], { 'model' => 'playlist', 'attribute' => 'name', 'type' => 'required' } + assert_includes response.parsed_body['errors'], { 'model' => 'playlist', 'attribute' => 'name', 'type' => 'blank' } end test 'should create playlist items during update' do diff --git a/test/controllers/plays_controller_test.rb b/test/controllers/plays_controller_test.rb index 9e70c240..ebaf4a4e 100644 --- a/test/controllers/plays_controller_test.rb +++ b/test/controllers/plays_controller_test.rb @@ -75,7 +75,7 @@ class PlaysControllerTest < ActionDispatch::IntegrationTest end assert_response :unprocessable_content - assert_includes response.parsed_body['errors'], { 'model' => 'play', 'attribute' => 'played_at', 'type' => 'required' } + assert_includes response.parsed_body['errors'], { 'model' => 'play', 'attribute' => 'played_at', 'type' => 'blank' } end test 'should get stats and not return play stats for other users' do diff --git a/test/controllers/tracks_controller_test.rb b/test/controllers/tracks_controller_test.rb index 8a6f4b0b..b3876151 100644 --- a/test/controllers/tracks_controller_test.rb +++ b/test/controllers/tracks_controller_test.rb @@ -73,7 +73,7 @@ class TracksControllerTest < ActionDispatch::IntegrationTest end assert_response :unprocessable_content - assert_includes response.parsed_body['errors'], { 'model' => 'track', 'attribute' => 'title', 'type' => 'required' } + assert_includes response.parsed_body['errors'], { 'model' => 'track', 'attribute' => 'title', 'type' => 'blank' } end test 'should not create track without album_id' do @@ -83,7 +83,7 @@ class TracksControllerTest < ActionDispatch::IntegrationTest end assert_response :unprocessable_content - assert_includes response.parsed_body['errors'], { 'model' => 'track', 'attribute' => 'album', 'type' => 'required' } + assert_includes response.parsed_body['errors'], { 'model' => 'track', 'attribute' => 'album', 'type' => 'blank' } end test 'should create track for moderator' do @@ -160,7 +160,7 @@ class TracksControllerTest < ActionDispatch::IntegrationTest assert_response :unprocessable_content assert_not_equal '', @track.reload.title - assert_includes response.parsed_body['errors'], { 'model' => 'track', 'attribute' => 'title', 'type' => 'required' } + assert_includes response.parsed_body['errors'], { 'model' => 'track', 'attribute' => 'title', 'type' => 'blank' } end test 'should clear review comment' do diff --git a/test/controllers/users_controller_test.rb b/test/controllers/users_controller_test.rb index 4cae79f3..1e14c62c 100644 --- a/test/controllers/users_controller_test.rb +++ b/test/controllers/users_controller_test.rb @@ -51,7 +51,7 @@ class UsersControllerTest < ActionDispatch::IntegrationTest end assert_response :unprocessable_content - assert_includes response.parsed_body['errors'], { 'model' => 'user', 'attribute' => 'name', 'type' => 'required' } + assert_includes response.parsed_body['errors'], { 'model' => 'user', 'attribute' => 'name', 'type' => 'blank' } end test 'should create user for admin' do @@ -102,7 +102,7 @@ class UsersControllerTest < ActionDispatch::IntegrationTest assert_response :unprocessable_content assert_not_equal '', @user.reload.name - assert_includes response.parsed_body['errors'], { 'model' => 'user', 'attribute' => 'name', 'type' => 'required' } + assert_includes response.parsed_body['errors'], { 'model' => 'user', 'attribute' => 'name', 'type' => 'blank' } end test 'should not update own permission if not admin' do From beeede33ae61b8e982336834a0463e81c65c4220 Mon Sep 17 00:00:00 2001 From: Robbe Van Petegem Date: Sat, 25 Jul 2026 13:40:30 +0200 Subject: [PATCH 5/7] Convert existing validations to specify type instead of message --- app/models/album.rb | 11 ---------- app/models/album_artist.rb | 12 +++++++++++ app/models/artist.rb | 4 ++-- app/models/location.rb | 4 ++-- app/models/playlist.rb | 2 +- app/models/playlist_item.rb | 2 +- test/models/album_artist_test.rb | 36 +++++++++++++++++++++++++++++++ test/models/album_test.rb | 26 ---------------------- test/models/artist_test.rb | 4 ++-- test/models/location_test.rb | 4 ++-- test/models/playlist_item_test.rb | 2 +- test/test_helper.rb | 16 ++++++++++++++ 12 files changed, 75 insertions(+), 48 deletions(-) diff --git a/app/models/album.rb b/app/models/album.rb index 6b48bf80..6a609437 100644 --- a/app/models/album.rb +++ b/app/models/album.rb @@ -40,7 +40,6 @@ class Album < ApplicationRecord before_validation :normalize_artist_order validates :title, presence: true - validate :album_artist_separators normalized_col_generator :title nilify_blank_values :edition_description, :review_comment @@ -81,14 +80,4 @@ def normalize_artist_order aa.save unless aa.new_record? end end - - def album_artist_separators - album_artists.each do |aa| - if aa.order == album_artists.to_a.count - errors.add(:album_artists, 'aa-last-no-separator') unless aa.separator.nil? - elsif aa.separator.nil? - errors.add(:album_artists, 'aa-separator') - end - end - end end diff --git a/app/models/album_artist.rb b/app/models/album_artist.rb index 52926fb0..388a8dca 100644 --- a/app/models/album_artist.rb +++ b/app/models/album_artist.rb @@ -33,6 +33,18 @@ class AlbumArtist < ApplicationRecord validates :name, presence: true validates :order, presence: true + validate :separator_not_nil, unless: :last_item? + validates :separator, absence: { allow_blank: false }, if: :last_item? normalized_col_generator :name + + private + + def last_item? + order == album.album_artists.size + end + + def separator_not_nil + errors.add(:separator, :blank) if separator.nil? + end end diff --git a/app/models/artist.rb b/app/models/artist.rb index b490b458..65b02429 100644 --- a/app/models/artist.rb +++ b/app/models/artist.rb @@ -42,8 +42,8 @@ class Artist < ApplicationRecord def merge(other) # we check if the artist to be merged have some overlap. If they do, we tell the user that they should resolve this first. - errors.add(:album_artists, 'aa.albums-overlap') if other.albums.map(&:id).intersect?(albums.map(&:id)) - errors.add(:track_artists, 'ta.tracks-overlap') if other.track_artists.map { |ta| [ta.track_id, ta.role] }.intersect?(track_artists.map { |ta| [ta.track_id, ta.role] }) + errors.add(:album_artists, :albums_overlap) if other.albums.map(&:id).intersect?(albums.map(&:id)) + errors.add(:track_artists, :tracks_overlap) if other.track_artists.map { |ta| [ta.track_id, ta.role] }.intersect?(track_artists.map { |ta| [ta.track_id, ta.role] }) return false if errors.present? other.album_artists.find_each do |aa| diff --git a/app/models/location.rb b/app/models/location.rb index a3fbce56..743e94f2 100644 --- a/app/models/location.rb +++ b/app/models/location.rb @@ -29,8 +29,8 @@ def expanded_path def cant_be_parent_or_subdir_of_other_location Location.find_each do |l| - errors.add(:path, 'path-is-subdirectoy') if expanded_path.fnmatch?(File.join(l.expanded_path, '**')) - errors.add(:path, 'path-is-parent') if l.expanded_path.fnmatch?(File.join(expanded_path, '**')) + errors.add(:path, :is_subdirectory) if expanded_path.fnmatch?(File.join(l.expanded_path, '**')) + errors.add(:path, :is_parent) if l.expanded_path.fnmatch?(File.join(expanded_path, '**')) end end diff --git a/app/models/playlist.rb b/app/models/playlist.rb index 28558888..975ea593 100644 --- a/app/models/playlist.rb +++ b/app/models/playlist.rb @@ -71,6 +71,6 @@ def item_ids_should_be_unique # When validating, we use `collect(&:item_id)` so we get the correct data # Regardless of whether the items were already saved doubles = items.collect(&:item_id).uniq! - errors.add(:items, 'item-ids-contains-double') unless doubles.nil? + errors.add(:items, :taken) unless doubles.nil? end end diff --git a/app/models/playlist_item.rb b/app/models/playlist_item.rb index 27b6f9f7..62789955 100644 --- a/app/models/playlist_item.rb +++ b/app/models/playlist_item.rb @@ -32,7 +32,7 @@ class PlaylistItem < ApplicationRecord private def item_type_should_match_playlist_type - errors.add(:item, 'item-type-different-from-playlist-type') unless item_type.downcase == playlist.playlist_type + errors.add(:item, :type_mismatch) unless item_type.downcase == playlist.playlist_type end def set_order diff --git a/test/models/album_artist_test.rb b/test/models/album_artist_test.rb index 2376e3dc..f00664cb 100644 --- a/test/models/album_artist_test.rb +++ b/test/models/album_artist_test.rb @@ -35,4 +35,40 @@ class AlbumArtistTest < ActiveSupport::TestCase assert_not album_artist.normalized_name.nil? assert_equal 'iouaaa', album_artist.normalized_name end + + test 'should be valid if all except last have separator' do + album = build(:album, album_artists: [build(:album_artist, separator: ' / ', order: 1), build(:album_artist, separator: nil, order: 2)]) + album_artist1 = album.album_artists.first + album_artist2 = album.album_artists.second + + assert_predicate album_artist1, :valid? + assert_predicate album_artist2, :valid? + end + + test 'should reject if album artists except last has no separator' do + album = build(:album, album_artists: [build(:album_artist, separator: nil, order: 1), build(:album_artist, separator: nil, order: 2)]) + album_artist1 = album.album_artists.first + album_artist2 = album.album_artists.second + + assert_not_predicate album_artist1, :valid? + assert_predicate album_artist2, :valid? + assert_error_of_kind album_artist1, :separator, :blank + end + + test 'should allow album artists with empty string as separator' do + album = build(:album, album_artists: [build(:album_artist, separator: '', order: 1), build(:album_artist, separator: nil, order: 2)]) + album_artist1 = album.album_artists.first + album_artist2 = album.album_artists.second + + assert_predicate album_artist1, :valid? + assert_predicate album_artist2, :valid? + end + + test 'should reject if last album artists has separator' do + album = build(:album, album_artists: [build(:album_artist, separator: ' / ')]) + album_artist1 = album.album_artists.first + + assert_not_predicate album_artist1, :valid? + assert_error_of_kind album_artist1, :separator, :present + end end diff --git a/test/models/album_test.rb b/test/models/album_test.rb index 69208871..730a323e 100644 --- a/test/models/album_test.rb +++ b/test/models/album_test.rb @@ -35,32 +35,6 @@ class AlbumTest < ActiveSupport::TestCase assert_equal 'iouaaa', album.normalized_title end - test 'should pass if all but last album artists have separator' do - album = build(:album, album_artists: [build(:album_artist, separator: ' / ', order: 1), build(:album_artist, separator: nil, order: 2)]) - - assert_predicate album, :valid? - end - - test 'should reject if album artists except last has no separator' do - album = build(:album, album_artists: [build(:album_artist, separator: nil, order: 1), build(:album_artist, separator: nil, order: 2)]) - - assert_not album.valid? - assert_not_empty album.errors[:album_artists] - end - - test 'should allow album artists with empty string as separator' do - album = build(:album, album_artists: [build(:album_artist, separator: '', order: 1), build(:album_artist, separator: nil, order: 2)]) - - assert_predicate album, :valid? - end - - test 'should reject if last album artists has separator' do - album = build(:album, album_artists: [build(:album_artist, separator: ' / ')]) - - assert_not album.valid? - assert_not_empty album.errors[:album_artists] - end - test 'should normalize order of album artists' do aa1 = build(:album_artist, order: 5) aa2 = build(:album_artist, order: 2) diff --git a/test/models/artist_test.rb b/test/models/artist_test.rb index ebab5bdc..6e4f5572 100644 --- a/test/models/artist_test.rb +++ b/test/models/artist_test.rb @@ -70,7 +70,7 @@ class ArtistTest < ActiveSupport::TestCase artist2.merge(artist1) end - assert_not_empty artist2.errors[:track_artists] + assert_error_of_kind artist2, :track_artists, :tracks_overlap end test 'should be able to merge artists if they share track_artist with different role' do @@ -142,7 +142,7 @@ class ArtistTest < ActiveSupport::TestCase artist2.merge(artist1) end - assert_not_empty artist2.errors[:album_artists] + assert_error_of_kind artist2, :album_artists, :albums_overlap end test 'should keep not override image during merge' do diff --git a/test/models/location_test.rb b/test/models/location_test.rb index 75d36cdd..a815a113 100644 --- a/test/models/location_test.rb +++ b/test/models/location_test.rb @@ -23,14 +23,14 @@ class LocationTest < ActiveSupport::TestCase child = build(:location, path: '/var/parent/music') assert_not child.valid? - assert_not_empty child.errors[:path] + assert_error_of_kind child, :path, :is_subdirectory end test 'location cant be parent of other location' do parent = build(:location, path: '/var') assert_not parent.valid? - assert_not_empty parent.errors[:path] + assert_error_of_kind parent, :path, :is_parent end test 'should be able to add similar siblings' do diff --git a/test/models/playlist_item_test.rb b/test/models/playlist_item_test.rb index 26d1d817..0a8fa164 100644 --- a/test/models/playlist_item_test.rb +++ b/test/models/playlist_item_test.rb @@ -35,7 +35,7 @@ class PlayListItemTest < ActiveSupport::TestCase item = build(:playlist_item, :for_track, playlist:) assert_not_predicate item, :valid? - assert_not_empty item.errors[:item] + assert_error_of_kind item, :item, :type_mismatch end test 'item should get order if not present' do diff --git a/test/test_helper.rb b/test/test_helper.rb index 88a3173c..5279c4ee 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -33,10 +33,26 @@ def with_stubbed_audio_file_convert(implementation = nil) end end +module ValidationAssertions + ## + # Assertion to check whether an object has an error with the specified attribute and type + # The object should implement `ActiveModel::Errors` for this to work + # + # For a list of the types for different validators, see [the rails guides](https://guides.rubyonrails.org/i18n.html#error-message-interpolation) + def assert_error_of_kind(object, attribute, type = :invalid) + attribute_errors = object.errors.filter { |e| e.attribute == attribute } + message = "Expected #{object.class.name} to have an error of type #{type} for #{attribute}, but no error was found." + message << "\nThe object does have the following errors for #{attribute}: #{attribute_errors}" if attribute_errors.present? + + assert object.errors.of_kind?(attribute, type), message + end +end + class ActiveSupport::TestCase include FactoryBot::Syntax::Methods include ActiveJob::TestHelper include AudioFileTestHelper + include ValidationAssertions # Run tests in parallel with specified workers parallelize(workers: :number_of_processors) From 082572ad3fd3d2b955dbea41da50dd58679cc33e Mon Sep 17 00:00:00 2001 From: Robbe Van Petegem Date: Sat, 25 Jul 2026 13:40:45 +0200 Subject: [PATCH 6/7] Drop error type map --- app/controllers/application_controller.rb | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 53b2e841..92d69d8e 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -2,9 +2,6 @@ class ApplicationController < ActionController::API include Pundit::Authorization include ActionController::HttpAuthentication::Token::ControllerMethods - # This map only includes the type of validation errors that we could have inside the app - ERROR_TYPE_MAP = { blank: :blank, taken: :taken }.freeze - etag { params[:page] } etag { params[:per_page] } @@ -46,7 +43,7 @@ def stale?(scope:, **) end def transform_error_for_json(object, error) - { model: object.model_name.singular, attribute: error.attribute, type: ERROR_TYPE_MAP[error.type] } + { model: object.model_name.singular, attribute: error.attribute, type: error.type } end # This method expects an instance of a class that includes `ActiveModel::Errors` From 83498a7d889c7d703aa8032242c3a497c574e888 Mon Sep 17 00:00:00 2001 From: Robbe Van Petegem Date: Sat, 25 Jul 2026 13:49:53 +0200 Subject: [PATCH 7/7] Fix merge --- app/models/artist.rb | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/models/artist.rb b/app/models/artist.rb index 65b02429..9eb3aba7 100644 --- a/app/models/artist.rb +++ b/app/models/artist.rb @@ -47,7 +47,9 @@ def merge(other) return false if errors.present? other.album_artists.find_each do |aa| - aa.update(artist_id: id) + # rubocop:disable Rails/SkipsModelValidations -- we skip validations, as this would check the separators while we're moving over the album artists + aa.update_columns(artist_id: id) + # rubocop:enable Rails/SkipsModelValidations end other.track_artists.find_each do |ta|