diff --git a/.circleci/config.yml b/.circleci/config.yml index cf0f5918eca..874e2b3bd93 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -10,6 +10,10 @@ jobs: - image: cimg/ruby:3.4.3-node steps: - checkout + - run: + name: Configure Bundler + command: | + bundle lock --add-platform ruby - ruby/install-deps - node/install: install-yarn: true @@ -21,6 +25,10 @@ jobs: - image: 'cimg/ruby:3.4.3-node' steps: - checkout + - run: + name: Configure Bundler + command: | + bundle lock --add-platform ruby - ruby/install-deps - ruby/rubocop-check: format: progress @@ -75,7 +83,6 @@ jobs: - run: name: Configure Bundler command: | - bundle config set --local force_ruby_platform true bundle lock --add-platform ruby - ruby/install-deps: clean-bundle: true diff --git a/.cloudbuild/cloudbuild-staging.yaml b/.cloudbuild/cloudbuild-staging.yaml index 2e3cbbaab78..c71dba70491 100644 --- a/.cloudbuild/cloudbuild-staging.yaml +++ b/.cloudbuild/cloudbuild-staging.yaml @@ -250,6 +250,7 @@ steps: - '--set-env-vars=DISCORD_AUTHENTICATION_URL=$_DISCORD_AUTHENTICATION_URL' - '--set-env-vars=PUBSUB_AUDIENCE=$_PUBSUB_AUDIENCE' - '--set-env-vars=PUBSUB_SERVICE_ACCOUNT_EMAIL=$_PUBSUB_SERVICE_ACCOUNT_EMAIL' + - '--set-env-vars=PUBSUB_TOPIC=$_PUBSUB_TOPIC' - '--set-env-vars=$_ENVS' - >- --labels=managed-by=gcp-cloud-build-deploy-cloud-run,commit-sha=$COMMIT_SHA,gcb-build-id=$BUILD_ID,gcb-trigger-id=$_TRIGGER_ID,$_LABELS diff --git a/.cloudbuild/cloudbuild.yaml b/.cloudbuild/cloudbuild.yaml index 775959054b9..fbda48d0844 100644 --- a/.cloudbuild/cloudbuild.yaml +++ b/.cloudbuild/cloudbuild.yaml @@ -121,6 +121,7 @@ steps: - '--set-env-vars=DISCORD_AUTHENTICATION_URL=$_DISCORD_AUTHENTICATION_URL' - '--set-env-vars=PUBSUB_AUDIENCE=$_PUBSUB_AUDIENCE' - '--set-env-vars=PUBSUB_SERVICE_ACCOUNT_EMAIL=$_PUBSUB_SERVICE_ACCOUNT_EMAIL' + - '--set-env-vars=PUBSUB_TOPIC=$_PUBSUB_TOPIC' - '--set-env-vars=$_ENVS' - >- --labels=managed-by=gcp-cloud-build-deploy-cloud-run,commit-sha=$COMMIT_SHA,gcb-build-id=$BUILD_ID,gcb-trigger-id=$_TRIGGER_ID,$_LABELS diff --git a/Gemfile b/Gemfile index 78cb53f5b67..0f47fbe45bb 100644 --- a/Gemfile +++ b/Gemfile @@ -36,6 +36,8 @@ gem 'discordrb', '~> 3.5', require: false gem 'doorkeeper' gem 'good_job', '~> 4.5' gem 'google-cloud-storage', '~> 1.25', require: false +gem 'google-cloud-video-transcoder' +gem 'google-id-token' gem 'holiday_jp' gem 'icalendar', '~> 2.8' gem 'interactor', '~> 3.0' @@ -58,6 +60,7 @@ gem 'omniauth-discord' gem 'omniauth-github', '~> 2.0.1' gem 'omniauth-rails_csrf_protection' gem 'opengraph_parser' +gem 'openssl' gem 'parser', '3.2.2.4' gem 'pg', '~> 1.4.6' gem 'postmark-rails' diff --git a/Gemfile.lock b/Gemfile.lock index ef9f130267c..a63d06956c3 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -199,6 +199,9 @@ GEM multipart-post (~> 2.0) faraday-net_http (3.4.2) net-http (~> 0.5) + faraday-retry (2.3.2) + faraday (~> 2.0) + ffi (1.17.1) ffi (1.17.1-aarch64-linux-gnu) ffi (1.17.1-aarch64-linux-musl) ffi (1.17.1-arm-linux-gnu) @@ -212,6 +215,16 @@ GEM fugit (1.12.1) et-orbi (~> 1.4) raabro (~> 1.4) + gapic-common (1.1.0) + faraday (>= 1.9, < 3.a) + faraday-retry (>= 1.0, < 3.a) + google-cloud-env (~> 2.2) + google-logging-utils (~> 0.1) + google-protobuf (>= 3.25, < 5.a) + googleapis-common-protos (~> 1.6) + googleapis-common-protos-types (~> 1.15) + googleauth (~> 1.12) + grpc (~> 1.66) globalid (1.3.0) activesupport (>= 6.1) good_job (4.12.1) @@ -249,7 +262,30 @@ GEM google-cloud-core (~> 1.6) googleauth (~> 1.9) mini_mime (~> 1.0) + google-cloud-video-transcoder (2.0.1) + google-cloud-core (~> 1.6) + google-cloud-video-transcoder-v1 (~> 2.0) + google-cloud-video-transcoder-v1 (2.2.0) + gapic-common (~> 1.0) + google-cloud-errors (~> 1.0) + google-id-token (1.4.2) + jwt (>= 1) google-logging-utils (0.2.0) + google-protobuf (4.32.0) + bigdecimal + rake (>= 13) + google-protobuf (4.32.0-arm64-darwin) + bigdecimal + rake (>= 13) + google-protobuf (4.32.0-x86_64-linux-gnu) + bigdecimal + rake (>= 13) + googleapis-common-protos (1.8.0) + google-protobuf (>= 3.18, < 5.a) + googleapis-common-protos-types (~> 1.20) + grpc (~> 1.41) + googleapis-common-protos-types (1.20.0) + google-protobuf (>= 3.18, < 5.a) googleauth (1.15.1) faraday (>= 1.0, < 3.a) google-cloud-env (~> 2.2) @@ -258,6 +294,15 @@ GEM multi_json (~> 1.11) os (>= 0.9, < 2.0) signet (>= 0.16, < 2.a) + grpc (1.74.1) + google-protobuf (>= 3.25, < 5.0) + googleapis-common-protos-types (~> 1.0) + grpc (1.74.1-arm64-darwin) + google-protobuf (>= 3.25, < 5.0) + googleapis-common-protos-types (~> 1.0) + grpc (1.74.1-x86_64-linux-gnu) + google-protobuf (>= 3.25, < 5.0) + googleapis-common-protos-types (~> 1.0) hashdiff (1.2.1) hashie (5.0.0) holiday_jp (0.8.1) @@ -426,6 +471,7 @@ GEM opengraph_parser (0.2.5) addressable nokogiri + openssl (4.0.0) opus-ruby (1.0.1) ffi os (1.1.4) @@ -743,6 +789,8 @@ DEPENDENCIES foreman good_job (~> 4.5) google-cloud-storage (~> 1.25) + google-cloud-video-transcoder + google-id-token holiday_jp icalendar (~> 2.8) image_processing (~> 1.12) @@ -772,6 +820,7 @@ DEPENDENCIES omniauth-github (~> 2.0.1) omniauth-rails_csrf_protection opengraph_parser + openssl parser (= 3.2.2.4) pg (~> 1.4.6) postmark-rails diff --git a/app/components/users/micro_reports/micro_report_component.html.slim b/app/components/users/micro_reports/micro_report_component.html.slim index 8301953e7b5..4efc533628f 100644 --- a/app/components/users/micro_reports/micro_report_component.html.slim +++ b/app/components/users/micro_reports/micro_report_component.html.slim @@ -1,20 +1,20 @@ .micro-report(id="micro_report_#{@micro_report.id}" data-micro_report_id="#{@micro_report.id}" data-micro_report_content="#{@micro_report.content}") .micro-report__start - = render 'users/icon', user: @user, link_class: 'micro-report__user-link', image_class: 'micro-report_user-icon' + = render 'users/icon', user: comment_user, link_class: 'micro-report__user-link', image_class: 'micro-report_user-icon' .micro-report__end .is-micro-report.micro-report-display header.micro-report__header h2.micro-report__title - = link_to user_path(@user), class: 'micro-report__title-link a-text-link' do - = @user.login_name + = link_to user_path(comment_user), class: 'micro-report__title-link a-text-link' do + = comment_user.login_name time.micro-report__created-at = posted_datetime .micro-report__body .a-short-text.is-sm.js-markdown-view = @micro_report.content .micro-report__footer - - if @micro_report.user == @current_user || admin_login? - .micro-report-actions + .micro-report-actions + - if @micro_report.comment_user == @current_user || @current_user&.admin? ul.micro-report-actions__items li.micro-report-actions__item button.micro-report-actions__action.is-edit.js-editor-button diff --git a/app/components/users/micro_reports/micro_report_component.rb b/app/components/users/micro_reports/micro_report_component.rb index 7e5eb756397..ca59b33b6a6 100644 --- a/app/components/users/micro_reports/micro_report_component.rb +++ b/app/components/users/micro_reports/micro_report_component.rb @@ -7,6 +7,10 @@ def initialize(user:, current_user:, micro_report:) @micro_report = micro_report end + def comment_user + @micro_report.comment_user + end + def posted_datetime time = @micro_report.created_at if time.to_date == Time.zone.today @@ -18,5 +22,7 @@ def posted_datetime end end - delegate :admin_login?, to: :helpers + def owner_post? + comment_user == @user + end end diff --git a/app/controllers/api/bookmarks_controller.rb b/app/controllers/api/bookmarks_controller.rb index ff6830baf51..1767fd7c066 100644 --- a/app/controllers/api/bookmarks_controller.rb +++ b/app/controllers/api/bookmarks_controller.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class API::BookmarksController < API::BaseController - PAGER_NUMBER = 25 + PAGER_NUMBER = 20 def index per = params[:per] || PAGER_NUMBER diff --git a/app/controllers/api/micro_reports_controller.rb b/app/controllers/api/micro_reports_controller.rb index d300a114aa8..39657b7ab7d 100644 --- a/app/controllers/api/micro_reports_controller.rb +++ b/app/controllers/api/micro_reports_controller.rb @@ -14,7 +14,12 @@ def update private def set_micro_report - @micro_report = current_user.admin? ? MicroReport.find(params[:id]) : current_user.micro_reports.find(params[:id]) + @micro_report = + if current_user.admin? + MicroReport.find(params[:id]) + else + current_user.authored_micro_reports.find(params[:id]) + end end def micro_report_params diff --git a/app/controllers/api/pub_sub_controller.rb b/app/controllers/api/pub_sub_controller.rb new file mode 100644 index 00000000000..3d01cc55ac9 --- /dev/null +++ b/app/controllers/api/pub_sub_controller.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +class API::PubSubController < API::BaseController + skip_before_action :verify_authenticity_token + skip_before_action :require_login_for_api + skip_before_action :basic_auth + before_action :authenticate_pubsub_token + + def create + result = ProcessTranscodingNotification.call(body: request.body.read) + + if result.success? + head :ok + else + Rails.logger.error("Failed to process transcoding notification: #{result.error}") + + if result.retryable == false + head :ok # 200系のステータスコードを返すとPub/Subは再送しない + else + head :internal_server_error + end + end + end + + private + + def authenticate_pubsub_token + authz = request.headers['Authorization'].to_s + token = authz[/\ABearer\s+(.+)\z/i, 1] + return if token && valid_pubsub_token?(token) + + Rails.logger.warn('Unauthorized Pub/Sub request') + head :unauthorized + end + + def valid_pubsub_token?(token) + validator = GoogleIDToken::Validator.new + expected_audience = "#{request.base_url}#{request.path}" + payload = validator.check(token, expected_audience) + + expected_sa_email = ENV['PUBSUB_SERVICE_ACCOUNT_EMAIL'] + sa_email_claim = payload['email'] + sa_email_claim == expected_sa_email + rescue GoogleIDToken::ValidationError => e + Rails.logger.warn("Invalid JWT: #{e.message}") + false + end +end diff --git a/app/controllers/billing_portal_controller.rb b/app/controllers/billing_portal_controller.rb index ed9738aec21..57f914de916 100644 --- a/app/controllers/billing_portal_controller.rb +++ b/app/controllers/billing_portal_controller.rb @@ -3,6 +3,6 @@ class BillingPortalController < ApplicationController def create session = Stripe::BillingPortal::Session.create(customer: current_user.customer_id) - redirect_to session.url + redirect_to session.url, allow_other_host: true end end diff --git a/app/controllers/current_user/bookmarks_controller.rb b/app/controllers/current_user/bookmarks_controller.rb index 6e89ba1a89c..d24d54732e6 100644 --- a/app/controllers/current_user/bookmarks_controller.rb +++ b/app/controllers/current_user/bookmarks_controller.rb @@ -1,7 +1,20 @@ # frozen_string_literal: true class CurrentUser::BookmarksController < ApplicationController - def index - @user = current_user + PAGER_NUMBER = 20 + + before_action :set_bookmarks, only: [:index] + + def index; end + + def destroy + current_user.bookmarks.find(params[:id]).destroy + head :no_content + end + + private + + def set_bookmarks + @bookmarks = current_user.bookmarks.preload(bookmarkable: :user).order(created_at: :desc, id: :desc).page(params[:page]).per(PAGER_NUMBER) end end diff --git a/app/controllers/notifications_controller.rb b/app/controllers/notifications_controller.rb index 823b08fde2b..3a3de6a5d12 100644 --- a/app/controllers/notifications_controller.rb +++ b/app/controllers/notifications_controller.rb @@ -5,6 +5,11 @@ class NotificationsController < ApplicationController def index @target = params[:target] + @notifications = UserNotificationsQuery.new( + user: current_user, + target: params[:target], + status: params[:status] + ).call.page(params[:page]) end def show diff --git a/app/controllers/training_completion_controller.rb b/app/controllers/training_completion_controller.rb index faa924c2e8e..076ba33bfe1 100644 --- a/app/controllers/training_completion_controller.rb +++ b/app/controllers/training_completion_controller.rb @@ -15,7 +15,7 @@ def create user = current_user current_user.cancel_participation_from_regular_events current_user.delete_and_assign_new_organizer - Newspaper.publish(:training_completion_create, { user: }) + ActiveSupport::Notifications.instrument('training_completion.create', user:) user.clear_github_data notify_to_user(user) notify_to_admins(user) diff --git a/app/controllers/users/micro_reports_controller.rb b/app/controllers/users/micro_reports_controller.rb index 15cb80d9b37..85fd6c62644 100644 --- a/app/controllers/users/micro_reports_controller.rb +++ b/app/controllers/users/micro_reports_controller.rb @@ -13,22 +13,28 @@ def index def create @micro_report = @user.micro_reports.build(micro_report_params) + @micro_report.comment_user = current_user - if current_user == @user && @micro_report.save + if @micro_report.save flash[:notice] = '分報を投稿しました。' else flash[:alert] = '分報の投稿に失敗しました。' end - redirect_to user_micro_reports_path(@user, page: @user.latest_micro_report_page) + redirect_to user_micro_reports_path(@user, page: @user.latest_micro_report_page(per_page: PAGER_NUMBER)) end def destroy + if !current_user.admin? && @micro_report.comment_user != current_user + redirect_to user_micro_reports_path(@user), alert: '権限がありません。' + return + end + @micro_report.destroy! referer_path = request.referer if page_out_of_range?(referer_path) - redirect_to user_micro_reports_path(@user, page: @user.latest_micro_report_page) + redirect_to user_micro_reports_path(@user, page: @user.latest_micro_report_page(per_page: PAGER_NUMBER)) else redirect_to referer_path end @@ -42,7 +48,12 @@ def set_user end def set_micro_report - @micro_report = current_user.admin? ? MicroReport.find(params[:id]) : current_user.micro_reports.find(params[:id]) + @micro_report = + if current_user.admin? + MicroReport.find(params[:id]) + else + current_user.authored_micro_reports.find(params[:id]) + end end def micro_report_params @@ -53,6 +64,6 @@ def page_out_of_range?(referer_path) matched_page_number = referer_path.match(/page=(\d+)/) page_number = matched_page_number ? matched_page_number[1] : FIRST_PAGE - MicroReport.page(page_number).out_of_range? + @user.micro_reports.page(page_number).out_of_range? end end diff --git a/app/interactors/process_transcoding_notification.rb b/app/interactors/process_transcoding_notification.rb new file mode 100644 index 00000000000..79350ccf139 --- /dev/null +++ b/app/interactors/process_transcoding_notification.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true + +class ProcessTranscodingNotification + include Interactor + + def call + message = parse_pubsub_message(context.body) + + job_name = message[:job_name] + job_state = message[:job_state] + job_error = message[:job_error] + + movie = find_movie(job_name) + context.fail!(retryable: false, error: "Movie not found for job_name: #{message[:job_name]}") unless movie + + handle_job_state(movie, job_name, job_state, job_error) + rescue Interactor::Failure + raise + rescue StandardError => e + Rails.logger.error("Unhandled error in ProcessTranscodingNotification: #{e.class}: #{e.message}") + context.fail!(retryable: true, error: e.message) + end + + private + + def parse_pubsub_message(body) + message = JSON.parse(body) + encoded_data = message.dig('message', 'data') + context.fail!(retryable: false, error: "Missing 'data' field in Pub/Sub message") unless encoded_data + + data = JSON.parse(Base64.decode64(encoded_data)) + job_name, job_state, job_error = data.fetch('job', {}).values_at('name', 'state', 'error') + + if job_name.blank? || job_state.blank? + context.fail!(retryable: false, + error: "Pub/Sub message missing required job fields: name=#{job_name}, state=#{job_state}") + end + { job_name:, job_state:, job_error: } + rescue JSON::ParserError, ArgumentError => e + context.fail!(retryable: false, error: "Invalid JSON/base64: #{e.message}") + end + + def find_movie(job_name) + movie_id = Transcoder::Client.new.get_movie_id(job_name) + Movie.find_by(id: movie_id) + rescue Google::Cloud::Error => e + Rails.logger.error("Failed to get movie_id for job #{job_name}: #{e.message}") + nil + end + + def handle_job_state(movie, job_name, job_state, job_error) + case job_state + when 'SUCCEEDED' + attach_transcoded_file(movie) + when 'FAILED', 'CANCELLED' + if audio_missing_error?(job_error) + Rails.logger.warn("Audio missing error detected for Movie #{movie.id}. Retrying without audio.") + TranscodeJob.perform_later(movie, force_video_only: true) + else + Rails.logger.error("Transcoding job #{job_name} for Movie #{movie.id} failed or cancelled.") + end + else + Rails.logger.warn("Unknown job state: #{job_state} for Movie #{movie.id}") + end + end + + def attach_transcoded_file(movie) + transcoded_movie = Transcoder::Movie.new(movie) + movie.movie_data.attach(io: transcoded_movie.data, filename: "#{movie.id}.mp4") + movie.save! + Rails.logger.info("Successfully attached transcoded movie for Movie #{movie.id}") + rescue StandardError => e + Rails.logger.error("Failed to attach transcoded movie for Movie #{movie.id}: #{e.message}") + raise + ensure + transcoded_movie.cleanup if transcoded_movie.respond_to?(:cleanup) + end + + def audio_missing_error?(error) + return false if error.blank? + + details = error['details'] || [] + details.any? do |detail| + field_violations = detail['fieldViolations'] || [] + field_violations.any? do |fv| + description = fv['description'].to_s + description.match?(/AudioMissing|audio.*not.*found|no.*audio.*stream/i) + end + end + rescue StandardError => e + Rails.logger.error("Failed to check audio missing error: #{e.message}") + false + end +end diff --git a/app/javascript/bookmarks-delete-button-visibility.js b/app/javascript/bookmarks-delete-button-visibility.js new file mode 100644 index 00000000000..47ad0e9f8a5 --- /dev/null +++ b/app/javascript/bookmarks-delete-button-visibility.js @@ -0,0 +1,6 @@ +export function toggleDeleteButtonVisibility(editToggle, deleteButtons) { + const displayStyle = editToggle.checked ? 'block' : 'none' + for (const button of deleteButtons) { + button.style.display = displayStyle + } +} diff --git a/app/javascript/bookmarks-edit-button.js b/app/javascript/bookmarks-edit-button.js deleted file mode 100644 index 76407116d92..00000000000 --- a/app/javascript/bookmarks-edit-button.js +++ /dev/null @@ -1,23 +0,0 @@ -document.addEventListener('DOMContentLoaded', () => { - const bookMarksEditButton = document.getElementById('bookmark_edit') - const bookmarkDeleteButton = document.getElementsByClassName( - 'js-bookmark-delete-button' - ) - if (bookMarksEditButton && bookmarkDeleteButton) { - for (let i = 0; i < bookmarkDeleteButton.length; i++) { - bookmarkDeleteButton[i].style.display = 'none' - } - - bookMarksEditButton.addEventListener('click', () => { - if (bookMarksEditButton.checked) { - for (let i = 0; i < bookmarkDeleteButton.length; i++) { - bookmarkDeleteButton[i].style.display = 'block' - } - } else { - for (let i = 0; i < bookmarkDeleteButton.length; i++) { - bookmarkDeleteButton[i].style.display = 'none' - } - } - }) - } -}) diff --git a/app/javascript/bookmarks.js b/app/javascript/bookmarks.js new file mode 100644 index 00000000000..7033ef8c2f9 --- /dev/null +++ b/app/javascript/bookmarks.js @@ -0,0 +1,83 @@ +import { get, destroy } from '@rails/request.js' +import { toggleDeleteButtonVisibility } from './bookmarks-delete-button-visibility' + +const EDIT_MODE_KEY = 'bookmark_edit_mode' +const DELETE_BUTTON_CLASS = 'js-bookmark-delete-button' + +document.addEventListener('DOMContentLoaded', () => { + const editButton = document.getElementById('bookmark_edit') + const pageBody = document.querySelector('.page-body') + if (!pageBody) return + + const savedValue = sessionStorage.getItem(EDIT_MODE_KEY) + const savedMode = savedValue === 'true' + initialize(savedMode) + + if (editButton) { + editButton.addEventListener('change', () => { + const deleteButtons = document.getElementsByClassName(DELETE_BUTTON_CLASS) + toggleDeleteButtonVisibility(editButton, deleteButtons) + sessionStorage.setItem(EDIT_MODE_KEY, editButton.checked) + }) + } + + document.addEventListener('click', async (event) => { + const deleteButton = event.target.closest('.bookmark-delete-button') + if (!deleteButton) return + + deleteButton.disabled = true + + try { + const url = deleteButton.dataset.url + const response = await destroy(url) + + if (!response.ok) { + throw new Error(`削除に失敗しました。(ステータス: ${response.status})`) + } + + const params = new URLSearchParams(location.search) + const currentPage = parseInt(params.get('page') || '1', 10) + const newPageMain = await fetchPageMain(currentPage) + + // 空ページの場合は1ページ前にフォールバック + let pageToShow = newPageMain + if (currentPage > 1 && newPageMain.querySelector('.o-empty-message')) { + pageToShow = await fetchPageMain(currentPage - 1) + } + document.querySelector('.page-body').replaceWith(pageToShow) + + const savedModeAfterDelete = + sessionStorage.getItem(EDIT_MODE_KEY) === 'true' + initialize(savedModeAfterDelete) + } catch (error) { + console.warn(error) + deleteButton.disabled = false + } + }) +}) + +window.addEventListener('beforeunload', () => { + const isBookmarkPage = location.pathname.includes('/current_user/bookmarks') + if (!isBookmarkPage) { + sessionStorage.removeItem(EDIT_MODE_KEY) + } +}) + +const fetchPageMain = async (page) => { + const bookmarkUrl = `/current_user/bookmarks?page=${page}` + const response = await get(bookmarkUrl, { responseKind: 'html' }) + const html = await response.text + const parser = new DOMParser() + const parsedDocument = parser.parseFromString(html, 'text/html') + return parsedDocument.querySelector('.page-body') +} + +const initialize = (deleteMode = false) => { + const editButton = document.getElementById('bookmark_edit') + const deleteButtons = document.getElementsByClassName(DELETE_BUTTON_CLASS) + + if (editButton && deleteButtons.length > 0) { + editButton.checked = deleteMode + toggleDeleteButtonVisibility(editButton, deleteButtons) + } +} diff --git a/app/javascript/components/Bookmarks.jsx b/app/javascript/components/Bookmarks.jsx deleted file mode 100644 index db737cd07f3..00000000000 --- a/app/javascript/components/Bookmarks.jsx +++ /dev/null @@ -1,240 +0,0 @@ -import React, { useState, useEffect, useRef } from 'react' -import useSWR, { useSWRConfig } from 'swr' -import fetcher from '../fetcher' -import { destroy } from '@rails/request.js' -import userIcon from '../user-icon.js' -import Pagination from './Pagination' -import usePage from './hooks/usePage' -import { formatDateToJapanese } from '../dateFormatter' - -export default function Bookmarks() { - const [editable, setEditable] = useState(false) - const per = 20 - const { page, setPage } = usePage() - const bookmarksUrl = `/api/bookmarks.json?page=${page}&per=${per}` - - const { data, error } = useSWR(bookmarksUrl, fetcher) - if (error) return <>エラーが発生しました。 - if (!data) return <>ロード中… - - if (data.totalPages === 0) { - return - } else { - return ( -
-
-
-
-
-
-

ブックマーク

-
-
-
-
-
-
- -
-
-
-
-
-
-
-
- {/* .page-main-header */} -
-
-
- {data.totalPages > 1 && ( - - )} -
-
- {data.bookmarks.map((bookmark) => { - return ( - - ) - })} -
-
- {data.totalPages > 1 && ( - - )} -
- {/* .container */} -
- {/* .page-body */} -
- {/* .page-main */} -
- ) - } -} - -const NoBookmarks = () => { - return ( -
-
-
-
-
-

ブックマーク

-
-
-
-
- {/* .page-main-header */} -
-
-
-
- -

- ブックマークはまだありません。 -

-
-
-
-
- ) -} - -const EditButton = ({ editable, setEditable }) => { - return ( - <> - - - - ) -} - -const Bookmark = ({ bookmark, editable, bookmarksUrl }) => { - // userIconの非React化により、useRef,useEffectを導入している。 - const userIconRef = useRef(null) - useEffect(() => { - const linkClass = 'card-list-item__user-link' - const imgClasses = ['card-list-item__user-icon', 'a-user-icon'] - - const userIconElement = userIcon({ - user: bookmark.user, - linkClass, - imgClasses - }) - - if (userIconRef.current) { - userIconRef.current.innerHTML = '' - userIconRef.current.appendChild(userIconElement) - } - }, [bookmark.user]) - - const date = bookmark.reported_on || bookmark.created_at - const createdAt = formatDateToJapanese(date) - const { mutate } = useSWRConfig() - const afterDelete = async (id) => { - try { - const response = await destroy(`/api/bookmarks/${id}.json`) - if (response.ok) { - mutate(bookmarksUrl) - } else { - console.warn('削除に失敗しました。') - } - } catch (error) { - console.warn(error) - } - } - - return ( -
-
- {bookmark.modelName === 'Talk' ? ( -
- ) : ( -
{bookmark.modelNameI18n}
- )} -
- - {bookmark.modelName !== 'Talk' && ( -
-
-
-

{bookmark.summary}

-
-
- -
- )} -
- {editable && ( - - )} -
-
- ) -} - -const DeleteButton = ({ id, afterDelete }) => { - return ( -
-
afterDelete(id)}> - 削除 -
-
- ) -} diff --git a/app/javascript/components/Notification.jsx b/app/javascript/components/Notification.jsx deleted file mode 100644 index d2c53592722..00000000000 --- a/app/javascript/components/Notification.jsx +++ /dev/null @@ -1,74 +0,0 @@ -import React, { useEffect, useRef } from 'react' -import userIcon from '../user-icon.js' -import dayjs from 'dayjs' -import ja from 'dayjs/locale/ja' -dayjs.locale(ja) - -export default function Notification({ notification }) { - // userIconの非React化により、useRef,useEffectを導入している。 - const userIconRef = useRef(null) - useEffect(() => { - const linkClass = 'card-list-item__user-link' - const imgClasses = ['card-list-item__user-icon', 'a-user-icon'] - - const userIconElement = userIcon({ - user: notification.sender, - linkClass, - imgClasses - }) - - if (userIconRef.current) { - userIconRef.current.innerHTML = '' - userIconRef.current.appendChild(userIconElement) - } - }, [notification.sender]) - - const createdAt = dayjs(notification.created_at).format( - 'YYYY年MM月DD日(ddd) HH:mm' - ) - - return ( -
-
-
-
-
-
-
- {notification.read === false && ( -
- 未読 -
- )} -

- -

-
-
-
-
-
-
-
- -
-
-
-
-
-
-
- ) -} diff --git a/app/javascript/components/Notifications.jsx b/app/javascript/components/Notifications.jsx deleted file mode 100644 index 946172e817a..00000000000 --- a/app/javascript/components/Notifications.jsx +++ /dev/null @@ -1,151 +0,0 @@ -import React from 'react' -import Notification from './Notification' -import LoadingListPlaceholder from './LoadingListPlaceholder' -import Pagination from './Pagination' -import UnconfirmedLink from './UnconfirmedLink' -import useSWR from 'swr' -import fetcher from '../fetcher' -import usePage from './hooks/usePage' - -export default function Notifications({ isMentor }) { - const per = 20 - const isUnreadPage = () => { - const params = new URLSearchParams(location.search) - return params.get('status') === 'unread' - } - const apiUrl = () => { - const searchParams = new URLSearchParams() - const params = new URLSearchParams(location.search) - const target = params.get('target') - if (target) { - searchParams.set('target', target) - } - - const status = params.get('status') - if (status) { - searchParams.set('status', status) - } - - const page = params.get('page') ?? 1 - searchParams.set('page', page) - - const url = new URL('api/notifications.json', location.origin) - url.search = searchParams - - return url.toString() - } - - const { page, setPage } = usePage() - const { data, error } = useSWR(apiUrl, fetcher) - - if (error) { - console.warn(error) - return
failed to load
- } else if (!data) { - return ( -
- -
- ) - } else if (data.notifications.length === 0) { - return ( - <> - -
-
- -
-

- {isUnreadPage() ? '未読の通知はありません' : '通知はありません'} -

-
- - ) - } - - return ( - <> - -
- {data.total_pages > 1 && ( - - )} -
- {data.notifications.map((notification) => { - return ( - - ) - })} -
- {isMentor && isUnreadPage() && ( - - )} - {data.total_pages > 1 && ( - - )} -
- - ) -} - -const FilterButton = () => { - const filterButtonUrl = (status) => { - const searchParams = new URLSearchParams() - const params = new URLSearchParams(location.search) - const target = params.get('target') - if (target) { - searchParams.set('target', target) - } - - if (status) { - searchParams.set('status', status) - } - - const url = new URL('/notifications', location.origin) - url.search = searchParams - - return url.toString() - } - - const params = new URLSearchParams(location.search) - return ( - - ) -} diff --git a/app/javascript/notifications_remove_after_open.js b/app/javascript/notifications_remove_after_open.js new file mode 100644 index 00000000000..5e92437f21a --- /dev/null +++ b/app/javascript/notifications_remove_after_open.js @@ -0,0 +1,27 @@ +document.addEventListener('DOMContentLoaded', () => { + const notificationPage = document.querySelector('#notifications') + if (!notificationPage) return + + const allOpenButton = document.querySelector( + '#js-shortcut-unconfirmed-links-open' + ) + if (!allOpenButton) return + + allOpenButton.addEventListener('click', () => { + document.querySelector('.card-list')?.remove() + allOpenButton.closest('.card-footer')?.remove() + document.querySelector('.a-border-tint')?.remove() + + const container = document.querySelector('.page-content') + if (container) { + container.innerHTML = ` +
+
+
+
+

未読の通知はありません

+
+ ` + } + }) +}) diff --git a/app/javascript/packs/application.js b/app/javascript/packs/application.js index 42c89b21ff5..a751411e65f 100644 --- a/app/javascript/packs/application.js +++ b/app/javascript/packs/application.js @@ -44,7 +44,7 @@ import '../learning-completion-message.js' import '../choices-ui.js' import '../training-info-toggler.js' import '../welcome_message_for_adviser.js' -import '../bookmarks-edit-button.js' +import '../bookmarks.js' import '../hibernation_agreements.js' import '../current-date-time-setter.js' import '../modal-switcher.js' @@ -85,6 +85,7 @@ import '../toast.js' import '../tag.js' import '../tag_edit.js' import '../bookmark-button.js' +import '../notifications_remove_after_open.js' import '../stylesheets/application.sass' diff --git a/app/jobs/transcode_job.rb b/app/jobs/transcode_job.rb new file mode 100644 index 00000000000..cb57cb5e8fe --- /dev/null +++ b/app/jobs/transcode_job.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +class TranscodeJob < ApplicationJob + MAX_RETRIES = 5 + RETRY_WAIT = 1.minute + + queue_as :default + + def perform(movie, force_video_only: false) + return unless Rails.application.config.transcoder['enabled'] + + Transcoder::Client.new(movie, force_video_only:).transcode + rescue Google::Cloud::Error => e + handle_transcode_error(e, movie) + end + + private + + def handle_transcode_error(error, movie) + if retryable?(error) && executions < MAX_RETRIES + log_retry(movie, error) + retry_job(wait: RETRY_WAIT) + return + end + + log_failure(movie, error) + notify_failure(movie, error) + end + + def retryable?(error) + code_str = error.respond_to?(:code) ? error.code.to_s.downcase : '' + %w[429 503 8 14 resource_exhausted unavailable].include?(code_str) + end + + def log_retry(movie, error) + Rails.logger.warn( + "Retrying Transcoding for Movie #{movie.id} " \ + "(code=#{error.code}, attempt=#{executions + 1})" + ) + end + + def log_failure(movie, error) + code = error.respond_to?(:code) ? error.code : 'n/a' + Rails.logger.error( + "Transcoding failed for Movie #{movie.id}: #{error.message} (code=#{code})" + ) + end + + def notify_failure(movie, error) + # 捕まえた例外は Rollbar に自動送信されないため、明示的に通知する。 + # movieの作成は既に完了しているため、ジョブ失敗でユーザー体験を損なわないよう例外は再送出しない + Rollbar.error(error, movie_id: movie.id) if defined?(Rollbar) + end +end diff --git a/app/models/micro_report.rb b/app/models/micro_report.rb index 3e36ce3bf97..8c8581a498f 100644 --- a/app/models/micro_report.rb +++ b/app/models/micro_report.rb @@ -4,5 +4,14 @@ class MicroReport < ApplicationRecord include Reactionable belongs_to :user + belongs_to :comment_user, class_name: 'User' validates :content, presence: true + + before_validation :set_default_comment_user, on: :create + + private + + def set_default_comment_user + self.comment_user ||= user + end end diff --git a/app/models/movie.rb b/app/models/movie.rb index 0d2e94808be..485b63c45e0 100644 --- a/app/models/movie.rb +++ b/app/models/movie.rb @@ -22,6 +22,8 @@ class Movie < ApplicationRecord scope :wip, -> { where(wip: true) } scope :by_tag, ->(tag) { tag.present? ? tagged_with(tag) : all } + after_create_commit :start_transcode_job, on: :create + def self.ransackable_attributes(_auth_object = nil) %w[title description wip created_at updated_at user_id] end @@ -29,4 +31,13 @@ def self.ransackable_attributes(_auth_object = nil) def self.ransackable_associations(_auth_object = nil) %w[user practices comments reactions watches bookmarks] end + + private + + def start_transcode_job + TranscodeJob.perform_later(self) + rescue StandardError => e + Rails.logger.error("Failed to enqueue TranscodeJob for Movie #{id}: #{e.message}") + raise + end end diff --git a/app/models/question_auto_closer.rb b/app/models/question_auto_closer.rb index b7c57771d7e..bbf5f22bb17 100644 --- a/app/models/question_auto_closer.rb +++ b/app/models/question_auto_closer.rb @@ -13,7 +13,7 @@ def post_warning system_user = User.find_by(login_name: SYSTEM_USER_LOGIN) return unless system_user - Question.not_solved.find_each do |question| + Question.not_wip.not_solved.find_each do |question| next unless should_post_warning?(question, system_user) create_warning_message(question, system_user) @@ -24,7 +24,7 @@ def close_and_select_best_answer system_user = User.find_by(login_name: SYSTEM_USER_LOGIN) return unless system_user - Question.not_solved.find_each do |question| + Question.not_wip.not_solved.find_each do |question| next unless should_close?(question, system_user) close_with_best_answer(question, system_user) @@ -34,7 +34,8 @@ def close_and_select_best_answer private def should_post_warning?(question, system_user) - last_activity_at = question.answers.order(created_at: :desc).first&.created_at || question.created_at + last_updated_answer = question.answers.order(updated_at: :desc, id: :desc).first + last_activity_at = [last_updated_answer&.updated_at, question.updated_at].compact.max return false unless last_activity_at <= 1.month.ago !system_message?(question, system_user, ANY_CLOSE_MESSAGE_PATTERN) && @@ -42,10 +43,11 @@ def should_post_warning?(question, system_user) end def create_warning_message(question, system_user) - question.answers.create!( + answer = question.answers.create!( user: system_user, description: AUTO_CLOSE_WARNING_MESSAGE ) + ActiveSupport::Notifications.instrument('answer.create', answer:) end def should_close?(question, system_user) diff --git a/app/models/retirement.rb b/app/models/retirement.rb index cdf794db87a..32aab615db7 100644 --- a/app/models/retirement.rb +++ b/app/models/retirement.rb @@ -33,7 +33,7 @@ def execute remove_as_event_organizer clear_github_info destroy_cards - publish_to_newspaper + publish notify true rescue ActiveRecord::RecordInvalid => e @@ -82,8 +82,8 @@ def remove_as_event_organizer @user.delete_and_assign_new_organizer end - def publish_to_newspaper - Newspaper.publish(:retirement_create, { user: @user }) + def publish + ActiveSupport::Notifications.instrument('retirement.create', user: @user) end def notify diff --git a/app/models/times_channel_destroyer.rb b/app/models/times_channel_destroyer.rb index 4a295bd9dde..d00cb5c5d40 100644 --- a/app/models/times_channel_destroyer.rb +++ b/app/models/times_channel_destroyer.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class TimesChannelDestroyer - def call(payload) + def call(_name, _started, _finished, _id, payload) user = payload[:user] return unless user.discord_profile.times_id diff --git a/app/models/transcoder/client.rb b/app/models/transcoder/client.rb new file mode 100644 index 00000000000..8825a540564 --- /dev/null +++ b/app/models/transcoder/client.rb @@ -0,0 +1,128 @@ +# frozen_string_literal: true + +module Transcoder + class Client + def initialize(movie = nil, config: nil, bucket_name: nil, project_id: nil, force_video_only: false) + @movie = movie + @config = config || default_config + @bucket_name = bucket_name || default_storage_config['bucket'] + @project_id = project_id || default_storage_config['project'] + @force_video_only = force_video_only + + validate_configuration + end + + def transcode + return if existing_job + + # 処理完了後にAPI::PubSubControllerへPub/Subで通知を送る + + transcoder_service.create_job( + parent: parent_path, + job: { + input_uri:, + output_uri:, + config: { + elementary_streams:, + mux_streams:, + pubsub_destination: { topic: pubsub_topic_path } + }, + labels: { movie_id: @movie.id.to_s } + } + ) + rescue Google::Cloud::Error => e + Rails.logger.error("Failed to create transcoding job for Movie #{@movie.id}: #{e.message}") + raise + end + + def get_movie_id(job_name) + return nil if job_name.blank? + + job = transcoder_service.get_job(name: job_name) + job&.labels&.[]('movie_id') + rescue Google::Cloud::Error => e + Rails.logger.error("Failed to get job #{job_name}: #{e.message}") + nil + end + + private + + def existing_job + transcoder_service.list_jobs(parent: parent_path).find do |job| + job.labels&.[]('movie_id') == @movie.id.to_s && + %w[PENDING RUNNING].include?(job.state.to_s) + end + rescue Google::Cloud::Error => e + Rails.logger.error("Failed to check existing jobs for Movie #{@movie.id}: #{e.message}") + nil + end + + def validate_configuration + raise ArgumentError, 'bucket_name is required' if @bucket_name.blank? + raise ArgumentError, 'project_id is required' if @project_id.blank? + + %w[location pubsub_topic].each do |key| + raise ArgumentError, "#{key} is required" if @config[key].blank? + end + end + + def elementary_streams + [video_stream_config, (!@force_video_only ? audio_stream_config : nil)].compact + end + + def video_stream_config + { key: 'video-stream', + video_stream: { h264: { height_pixels: @config['video_height'], width_pixels: @config['video_width'], bitrate_bps: @config['video_bitrate'], + frame_rate: @config['video_frame_rate'] } } } + end + + def audio_stream_config + { key: 'audio-stream', audio_stream: { codec: @config['audio_codec'], bitrate_bps: @config['audio_bitrate'] } } + end + + def mux_streams + [{ key: 'muxed-stream', container: @config['container'], elementary_streams: ['video-stream', ('audio-stream' unless @force_video_only)].compact }] + end + + def transcoder_service + Google::Cloud::Video::Transcoder.transcoder_service + end + + def location + @config['location'] + end + + def service_name + ActiveStorage::Blob.service.name.to_s + end + + def default_storage_config + Rails.application.config.active_storage.service_configurations[ActiveStorage::Blob.service.name.to_s] || raise('ActiveStorage service not found') + end + + def input_uri + key = @movie&.movie_data&.blob&.key + raise ArgumentError, 'Movie and movie_data blob.key are required' if key.blank? + + "gs://#{@bucket_name}/#{key}" + end + + def output_uri + raise ArgumentError, 'Movie ID is required' unless @movie&.id + + "gs://#{@bucket_name}/#{@movie.id}/" + end + + def parent_path + "projects/#{@project_id}/locations/#{location}" + end + + def pubsub_topic_path + "projects/#{@project_id}/topics/#{@config['pubsub_topic']}" + end + + def default_config + Rails.application.config.transcoder + end + end +end diff --git a/app/models/transcoder/movie.rb b/app/models/transcoder/movie.rb new file mode 100644 index 00000000000..96477601582 --- /dev/null +++ b/app/models/transcoder/movie.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +module Transcoder + class Movie + def initialize(movie, bucket_name: nil, path: nil) + @movie = movie + @bucket_name = bucket_name || default_bucket_name + @path = path || default_path + @tempfile = nil + end + + def data + raise 'Transcoded file not found' unless file&.exists? + + @tempfile = Tempfile.new([@movie.id.to_s, '.mp4'], binmode: true) + + file.download @tempfile.path + + @tempfile.rewind + @tempfile + rescue Google::Cloud::Storage::FileVerificationError => e + Rails.logger.error "File verification failed: #{e.message}" + raise + rescue Google::Cloud::Error => e + Rails.logger.error "Failed to download transcoded file: #{e.message}" + raise + end + + def cleanup + cleanup_gcs + cleanup_tempfile + end + + private + + def cleanup_gcs + return unless file&.exists? + + file.delete + rescue Google::Cloud::Storage::FileVerificationError, Google::Cloud::Error => e + # クリーンアップ失敗は処理結果に影響させない + Rails.logger.warn "Cleanup skipped (GCS): #{e.class}: #{e.message}" + end + + def cleanup_tempfile + return unless @tempfile + + @tempfile.close! + @tempfile = nil + rescue StandardError => e + # クリーンアップ失敗は処理結果に影響させない + Rails.logger.warn "Cleanup skipped (Tempfile): #{e.class}: #{e.message}" + end + + def file + @file ||= bucket.file(@path) + end + + def bucket + @bucket ||= begin + bucket_obj = storage.bucket(@bucket_name) + raise "Bucket not found or inaccessible: #{@bucket_name}" unless bucket_obj + + bucket_obj + end + end + + def storage + Google::Cloud::Storage.new + end + + def default_bucket_name + service_name = ActiveStorage::Blob.service.name.to_s + config = Rails.application.config.active_storage.service_configurations[service_name] + raise "ActiveStorage service configuration not found: #{service_name}" unless config + raise "Bucket not configured for service: #{service_name}" unless config['bucket'] + + config['bucket'] + end + + def default_path + "#{@movie.id}/muxed-stream.mp4" + end + end +end diff --git a/app/models/unfinished_data_destroyer.rb b/app/models/unfinished_data_destroyer.rb index 6af72f7484e..38ae6d0240d 100644 --- a/app/models/unfinished_data_destroyer.rb +++ b/app/models/unfinished_data_destroyer.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class UnfinishedDataDestroyer - def call(payload) + def call(_name, _started, _finished, _id, payload) user = payload[:user] Product.where(user:).unchecked.destroy_all Report.where(user:).wip.destroy_all diff --git a/app/models/user.rb b/app/models/user.rb index 7cf7eb62a57..22c8fc29d6d 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -130,6 +130,7 @@ class User < ApplicationRecord # rubocop:todo Metrics/ClassLength has_many :request_retirements, dependent: :destroy has_one :targeted_request_retirement, class_name: 'RequestRetirement', foreign_key: 'target_user_id', dependent: :destroy, inverse_of: :target_user has_many :micro_reports, dependent: :destroy + has_many :authored_micro_reports, class_name: 'MicroReport', foreign_key: 'comment_user_id', dependent: :destroy, inverse_of: :comment_user has_many :learning_time_frames_users, dependent: :destroy has_many :participate_events, @@ -921,8 +922,8 @@ def grant_course? course_id.present? && course&.grant? end - def latest_micro_report_page - [micro_reports.page.total_pages, 1].max + def latest_micro_report_page(per_page: 25) + [micro_reports.page.per(per_page).total_pages, 1].max end def mark_mail_as_sent_before_auto_retire diff --git a/app/queries/user_notifications_query.rb b/app/queries/user_notifications_query.rb index 6405236deb4..b1afcff5503 100644 --- a/app/queries/user_notifications_query.rb +++ b/app/queries/user_notifications_query.rb @@ -5,7 +5,7 @@ class UserNotificationsQuery < Patterns::Query private - def initialize(relation = Notification.all, user:, target: nil, status: nil) + def initialize(relation = Notification.all, user:, target:, status:) super(relation) @user = user @target = target @@ -14,7 +14,7 @@ def initialize(relation = Notification.all, user:, target: nil, status: nil) def query latest_notifications = @user.notifications - .by_target(@target) + .by_target(validated_target) .by_read_status(@status) .latest_of_each_link @@ -22,4 +22,9 @@ def query .from(latest_notifications, :notifications) .order(created_at: :desc) end + + def validated_target + target = @target&.to_sym + Notification::TARGETS_TO_KINDS.key?(target) ? target : nil + end end diff --git a/app/views/current_user/bookmarks/_empty.html.slim b/app/views/current_user/bookmarks/_empty.html.slim new file mode 100644 index 00000000000..2cf4dcbc87b --- /dev/null +++ b/app/views/current_user/bookmarks/_empty.html.slim @@ -0,0 +1,11 @@ +.page-main-header + .container + .page-main-header__inner + .page-main-header__start + h1.page-main-header__title ブックマーク +hr.a-border +.page-body + .o-empty-message + .o-empty-message__icon + i.fa-regular.fa-face-sad-tear + p.o-empty-message__text ブックマークはまだありません。 diff --git a/app/views/current_user/bookmarks/_list.html.slim b/app/views/current_user/bookmarks/_list.html.slim new file mode 100644 index 00000000000..6d6d8a817c8 --- /dev/null +++ b/app/views/current_user/bookmarks/_list.html.slim @@ -0,0 +1,59 @@ +.page-main-header + .container + .page-main-header__inner + .page-main-header__start + h1.page-main-header__title ブックマーク + .page-main-header__end + .page-header-actions + .page-header-actions__items + .page-header-actions__item.js-bookmark-edit-toggle + .form-item.is-inline + label.a-form-label for="card-list-tools__action" + | 編集 + label.a-on-off-checkbox.is-sm + input#bookmark_edit[name="card-list-tools__action" type="checkbox"] + span#spec-edit-mode +hr.a-border +.page-body + .container.is-md + - if bookmarks.total_pages > 1 + = paginate bookmarks + + .card-list.a-card + .card-list__items + - bookmarks.each do |bookmark| + .card-list-item class="is-#{bookmark.bookmarkable_type.downcase}" id="bookmark-#{bookmark.id}" + .card-list-item__inner + - if bookmark.bookmarkable_type == 'Talk' + = image_tag bookmark.bookmarkable.user.avatar_url, class: 'card-list-item__user-icon a-user-icon' + - else + .card-list-item__label = bookmark.bookmarkable.model_name.human + + .card-list-item__rows + .card-list-item__row + .card-list-item-title + .card-list-item-title__title + = link_to polymorphic_path(bookmark.bookmarkable), class: 'card-list-item-title__link a-text-link' do + = bookmark.bookmarkable_type == 'Talk' ? "#{bookmark.bookmarkable.user.long_name} さんの相談部屋" : bookmark.bookmarkable.title + + - if bookmark.bookmarkable_type != 'Talk' + .card-list-item__row + .card-list-item__summary + p = truncate(search_summary(bookmark.bookmarkable, ''), length: 100) + + .card-list-item__row + .card-list-item-meta + .card-list-item-meta__item + = link_to bookmark.bookmarkable.user.url, class: 'a-user-name' do + = "#{bookmark.bookmarkable.user.login_name}(#{bookmark.bookmarkable.user.name_kana})" + .card-list-item-meta__item + - date = bookmark.bookmarkable_type == 'Report' ? bookmark.bookmarkable.reported_on.to_time : bookmark.bookmarkable.created_at + time.a-meta datetime=date + = l(date, format: :default) + + .card-list-item__option + .js-bookmark-delete-button + button.bookmark-delete-button.a-bookmark-button.a-button.is-sm.is-block.is-main[type="button" data-url=current_user_bookmark_path(bookmark)] 削除 + + - if bookmarks.total_pages > 1 + = paginate bookmarks diff --git a/app/views/current_user/bookmarks/index.html.slim b/app/views/current_user/bookmarks/index.html.slim index e054472fc3c..94670f816d6 100644 --- a/app/views/current_user/bookmarks/index.html.slim +++ b/app/views/current_user/bookmarks/index.html.slim @@ -4,4 +4,8 @@ = render 'home/page_header' = dashboard_page_tabs(active_tab: 'ブックマーク') -= react_component 'Bookmarks' +.page-main + - if @bookmarks.empty? + = render 'current_user/bookmarks/empty' + - else + = render 'current_user/bookmarks/list', bookmarks: @bookmarks diff --git a/app/views/notifications/_filter_button.html.slim b/app/views/notifications/_filter_button.html.slim new file mode 100644 index 00000000000..bdee6f86148 --- /dev/null +++ b/app/views/notifications/_filter_button.html.slim @@ -0,0 +1,12 @@ +nav.pill-nav + .container + ul.pill-nav__items + li.pill-nav__item + = link_to '未読', + notifications_path(status: 'unread', target: params[:target]), + class: "pill-nav__item-link #{params[:status] == 'unread' ? 'is-active' : ''}" + + li.pill-nav__item + = link_to '全て', + notifications_path(target: params[:target]), + class: "pill-nav__item-link #{params[:status] == 'unread' ? '' : 'is-active'}" diff --git a/app/views/notifications/_notification.html.slim b/app/views/notifications/_notification.html.slim new file mode 100644 index 00000000000..35b2062e900 --- /dev/null +++ b/app/views/notifications/_notification.html.slim @@ -0,0 +1,30 @@ +- read_class = notification.read? ? 'is-read' : 'is-unread' + +.card-list-item class=read_class + .card-list-item__inner + .card-list-item__user + = link_to user_path(notification.sender), class: 'card-list-item__user-link' do + = image_tag notification.sender.avatar_url, + class: 'card-list-item__user-icon a-user-icon', + alt: notification.sender.login_name + + .card-list-item__rows + .card-list-item__row + .card-list-item-title + .card-list-item-title__start + - if !notification.read? + .a-list-item-badge.is-unread + span 未読 + + h2.card-list-item-title__title[itemProp="name"] + = link_to notification.path, + class: 'card-list-item-title__link a-text-link js-unconfirmed-link', + itemProp: 'url' do + span.card-list-item-title__link-label = notification.message + + .card-list-item__row + .card-list-item-meta + .card-list-item-meta__items + .card-list-item-meta__item + time.a-meta dateTime=notification.created_at + = l(notification.created_at, format: :long) diff --git a/app/views/notifications/index.html.slim b/app/views/notifications/index.html.slim index ff7fb73fdd2..bc83de0d866 100644 --- a/app/views/notifications/index.html.slim +++ b/app/views/notifications/index.html.slim @@ -44,4 +44,25 @@ main.page-main hr.a-border .page-body .container.is-md - = react_component('Notifications', isMentor: mentor_login?) + = render 'filter_button' + - if @notifications.empty? + .o-empty-message + .o-empty-message__icon + .i.fa-regular.fa-smile + p.o-empty-message__text + = params[:status] == 'unread' ? '未読の通知はありません' : '通知はありません' + - else + .page-content#notifications + - if @notifications.total_pages > 1 + nav.pagination + = paginate @notifications + .card-list.a-card + - @notifications.each do |notification| + = render 'notification', notification: notification + + - if current_user.mentor? && params[:status] == 'unread' + = render 'application/unconfirmed_links_open', label: '未読の通知を一括で開く' + + - if @notifications.total_pages > 1 + nav.pagination + = paginate @notifications diff --git a/app/views/users/micro_reports/index.html.slim b/app/views/users/micro_reports/index.html.slim index f3fd2a59934..497f171956a 100644 --- a/app/views/users/micro_reports/index.html.slim +++ b/app/views/users/micro_reports/index.html.slim @@ -8,13 +8,9 @@ .page-body.pb-0 .page-content - - if current_user == @user - .micro-reports__end - = render(Users::MicroReports::FormComponent.new(user: @user)) - - micro_reports_class = 'micro-reports-with-form' - - else - - micro_reports_class = 'micro-reports-without-form' - .micro-reports#js-micro-reports class=micro_reports_class + .micro-reports__end + = render(Users::MicroReports::FormComponent.new(user: @user)) + .micro-reports#js-micro-reports.micro-reports-with-form .micro-reports__start .container.is-md .micro-reports__items diff --git a/config/application.rb b/config/application.rb index c375bf6a840..2c38dcf2524 100644 --- a/config/application.rb +++ b/config/application.rb @@ -34,6 +34,14 @@ class Application < Rails::Application config.active_storage.variant_processor = :vips + # Allow reading legacy Active Storage URLs that were signed with Marshal serializer + # Rails 7.2 defaults to :json, which cannot read old URLs + config.active_support.message_serializer = :json_allow_marshal + + # Use SHA1 for key generator to support legacy Active Storage URLs + # Old URLs were signed with SHA1-based keys + config.active_support.key_generator_hash_digest_class = OpenSSL::Digest::SHA1 + # Disable foreign key validation for fixtures # Cloud SQL restricts access to pg_constraint system table config.active_record.verify_foreign_keys_for_fixtures = false @@ -43,5 +51,7 @@ class Application < Rails::Application config.to_prepare do Doorkeeper::AuthorizationsController.layout "authorization" end + + config.transcoder = config_for(:transcoder) end end diff --git a/config/initializers/active_support_notifications.rb b/config/initializers/active_support_notifications.rb index cb8d2eea85a..58924ff9cc8 100644 --- a/config/initializers/active_support_notifications.rb +++ b/config/initializers/active_support_notifications.rb @@ -60,4 +60,12 @@ question_notifier = QuestionNotifier.new ActiveSupport::Notifications.subscribe('question.create', question_notifier) ActiveSupport::Notifications.subscribe('question.update', question_notifier) + + unfinished_data_destroyer = UnfinishedDataDestroyer.new + ActiveSupport::Notifications.subscribe('retirement.create', unfinished_data_destroyer) + ActiveSupport::Notifications.subscribe('training_completion.create', unfinished_data_destroyer) + + times_channel_destroyer = TimesChannelDestroyer.new + ActiveSupport::Notifications.subscribe('retirement.create', times_channel_destroyer) + ActiveSupport::Notifications.subscribe('training_completion.create', times_channel_destroyer) end diff --git a/config/initializers/newspaper.rb b/config/initializers/newspaper.rb index 08993429d2e..83906530ec1 100644 --- a/config/initializers/newspaper.rb +++ b/config/initializers/newspaper.rb @@ -1,13 +1,5 @@ # frozen_string_literal: true Rails.configuration.after_initialize do - unfinished_data_destroyer = UnfinishedDataDestroyer.new - Newspaper.subscribe(:retirement_create, unfinished_data_destroyer) - Newspaper.subscribe(:training_completion_create, unfinished_data_destroyer) - - times_channel_destroyer = TimesChannelDestroyer.new - Newspaper.subscribe(:retirement_create, times_channel_destroyer) - Newspaper.subscribe(:training_completion_create, times_channel_destroyer) - Newspaper.subscribe(:came_comment_in_talk, CommentNotifierForAdmin.new) end diff --git a/config/routes/api.rb b/config/routes/api.rb index 4b5b259a107..b9545448c1b 100644 --- a/config/routes/api.rb +++ b/config/routes/api.rb @@ -9,6 +9,7 @@ namespace 'mentor' do resources :practices, only: %i(index) end + resource :pubsub, controller:"pub_sub", only: %i(create), defaults: { format: :json } resource :session, controller: "session", only: %i(create) resource :image, controller: "image", only: %i(create) resources :courses, only: %i() do diff --git a/config/routes/current_user.rb b/config/routes/current_user.rb index dda7d75dc03..ac7e86d9f7e 100644 --- a/config/routes/current_user.rb +++ b/config/routes/current_user.rb @@ -5,6 +5,6 @@ resources :reports, only: %i(index) resources :products, only: %i(index) resources :watches, only: %i(index) - resources :bookmarks, only: %i(index) + resources :bookmarks, only: %i(index destroy) end end diff --git a/config/transcoder.yml b/config/transcoder.yml new file mode 100644 index 00000000000..816a2e5b405 --- /dev/null +++ b/config/transcoder.yml @@ -0,0 +1,21 @@ +default: &default + enabled: false + video_height: 1080 + video_width: 1920 + video_bitrate: 12000000 + video_frame_rate: 120 + audio_codec: 'aac' + audio_bitrate: 128000 + container: 'mp4' + location: 'asia-northeast1' + pubsub_topic: <%= ENV['PUBSUB_TOPIC'] %> + +production: + <<: *default + enabled: true + +development: + <<: *default + +test: + <<: *default diff --git a/db/fixtures/micro_reports.yml b/db/fixtures/micro_reports.yml index 52074ba2065..e4c05bc2ef0 100644 --- a/db/fixtures/micro_reports.yml +++ b/db/fixtures/micro_reports.yml @@ -1,21 +1,25 @@ micro_report1: user: hajime + comment_user: hajime content: 初めての分報です。 created_at: '2022-01-01 00:00:00' micro_report2: user: hajime + comment_user: hajime content: 2個目の分報です。 created_at: '2022-01-02 00:00:00' micro_report3: user: hajime + comment_user: hajime content: 3個目の分報です。 created_at: '2022-01-03 00:00:00' <% (4..28).each do |id| %> micro_report<%= id %>: user: hajime + comment_user: hajime content: <%= id %>個目の分報です。 created_at: '2022-01-04 00:00:00' <% end %> diff --git a/db/fixtures/questions.yml b/db/fixtures/questions.yml index 94b40b0b40d..71becf988fa 100644 --- a/db/fixtures/questions.yml +++ b/db/fixtures/questions.yml @@ -113,6 +113,7 @@ question15: user: kimura practice: practice1 created_at: "2022-01-14" + updated_at: "2022-01-14" wip: true question16: @@ -139,4 +140,14 @@ question51: user: kimura practice: practice1 created_at: <%= Time.current - 1.month - 1.week %> + updated_at: <%= Time.current - 1.month - 1.week %> published_at: <%= Time.current - 1.month - 1.week %> + +question52: + title: 自動クローズ前に警告される質問 + description: 自動クローズ前に警告される質問です。 + user: kimura + practice: practice1 + created_at: <%= Time.current - 1.month %> + updated_at: <%= Time.current - 1.month %> + published_at: <%= Time.current - 1.month %> diff --git a/db/migrate/20251002152627_add_comment_users_to_micro_reports.rb b/db/migrate/20251002152627_add_comment_users_to_micro_reports.rb new file mode 100644 index 00000000000..db325c11ad1 --- /dev/null +++ b/db/migrate/20251002152627_add_comment_users_to_micro_reports.rb @@ -0,0 +1,6 @@ +class AddCommentUsersToMicroReports < ActiveRecord::Migration[6.1] + def change + add_column :micro_reports, :comment_user_id, :bigint + add_foreign_key :micro_reports, :users, column: :comment_user_id + end +end diff --git a/db/migrate/20251103051313_backfill_comment_user_id_on_micro_reports.rb b/db/migrate/20251103051313_backfill_comment_user_id_on_micro_reports.rb new file mode 100644 index 00000000000..a6b55488039 --- /dev/null +++ b/db/migrate/20251103051313_backfill_comment_user_id_on_micro_reports.rb @@ -0,0 +1,11 @@ +class BackfillCommentUserIdOnMicroReports < ActiveRecord::Migration[6.1] + def up + MicroReport.where(comment_user_id: nil).find_each do |report| + report.update!(comment_user_id: report.user_id) + end + end + + def down + MicroReport.update_all(comment_user_id: nil) + end +end diff --git a/db/migrate/20251103053813_backfill_comment_user_id_on_micro_reports_v2.rb b/db/migrate/20251103053813_backfill_comment_user_id_on_micro_reports_v2.rb new file mode 100644 index 00000000000..961c89ddffe --- /dev/null +++ b/db/migrate/20251103053813_backfill_comment_user_id_on_micro_reports_v2.rb @@ -0,0 +1,12 @@ +class BackfillCommentUserIdOnMicroReportsV2 < ActiveRecord::Migration[6.1] + def up + MicroReport.where(comment_user_id: nil).find_each do |report| + next if report.user_id.blank? + report.update_column(:comment_user_id, report.user_id) + end + end + + def down + MicroReport.update_all(comment_user_id: nil) + end +end diff --git a/db/migrate/20251220000001_add_missing_columns_to_good_jobs.rb b/db/migrate/20251220000001_add_missing_columns_to_good_jobs.rb new file mode 100644 index 00000000000..79caa668843 --- /dev/null +++ b/db/migrate/20251220000001_add_missing_columns_to_good_jobs.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +class AddMissingColumnsToGoodJobs < ActiveRecord::Migration[7.2] + def change + # Add missing columns to good_jobs table if they don't exist + add_column :good_jobs, :error_event, :integer, limit: 2 unless column_exists?(:good_jobs, :error_event) + add_column :good_jobs, :labels, :text, array: true unless column_exists?(:good_jobs, :labels) + add_column :good_jobs, :locked_by_id, :uuid unless column_exists?(:good_jobs, :locked_by_id) + add_column :good_jobs, :locked_at, :datetime unless column_exists?(:good_jobs, :locked_at) + add_column :good_jobs, :is_discrete, :boolean unless column_exists?(:good_jobs, :is_discrete) + add_column :good_jobs, :executions_count, :integer unless column_exists?(:good_jobs, :executions_count) + add_column :good_jobs, :job_class, :text unless column_exists?(:good_jobs, :job_class) + add_column :good_jobs, :batch_id, :uuid unless column_exists?(:good_jobs, :batch_id) + add_column :good_jobs, :batch_callback_id, :uuid unless column_exists?(:good_jobs, :batch_callback_id) + + # Add missing indexes + unless index_exists?(:good_jobs, :labels, name: 'index_good_jobs_on_labels') + add_index :good_jobs, :labels, using: :gin, where: "(labels IS NOT NULL)", name: :index_good_jobs_on_labels + end + + unless index_exists?(:good_jobs, :locked_by_id, name: 'index_good_jobs_on_locked_by_id') + add_index :good_jobs, :locked_by_id, where: "locked_by_id IS NOT NULL", name: "index_good_jobs_on_locked_by_id" + end + + unless index_exists?(:good_jobs, :batch_id, name: 'index_good_jobs_on_batch_id') + add_index :good_jobs, [:batch_id], where: "batch_id IS NOT NULL", name: 'index_good_jobs_on_batch_id' + end + + unless index_exists?(:good_jobs, :batch_callback_id, name: 'index_good_jobs_on_batch_callback_id') + add_index :good_jobs, [:batch_callback_id], where: "batch_callback_id IS NOT NULL", name: 'index_good_jobs_on_batch_callback_id' + end + + unless index_exists?(:good_jobs, :job_class, name: 'index_good_jobs_on_job_class') + add_index :good_jobs, :job_class, name: :index_good_jobs_on_job_class + end + + unless index_exists?(:good_jobs, [:priority, :scheduled_at], name: 'index_good_jobs_on_priority_scheduled_at_unfinished_unlocked') + add_index :good_jobs, [:priority, :scheduled_at], order: { priority: "ASC NULLS LAST", scheduled_at: :asc }, + where: "finished_at IS NULL AND locked_by_id IS NULL", name: :index_good_jobs_on_priority_scheduled_at_unfinished_unlocked + end + end +end diff --git a/db/migrate/20251223000001_add_missing_good_job_tables.rb b/db/migrate/20251223000001_add_missing_good_job_tables.rb new file mode 100644 index 00000000000..08a47d3517d --- /dev/null +++ b/db/migrate/20251223000001_add_missing_good_job_tables.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +class AddMissingGoodJobTables < ActiveRecord::Migration[7.2] + def change + # Create good_job_batches table if it doesn't exist + unless table_exists?(:good_job_batches) + create_table :good_job_batches, id: :uuid do |t| + t.timestamps + t.text :description + t.jsonb :serialized_properties + t.text :on_finish + t.text :on_success + t.text :on_discard + t.text :callback_queue_name + t.integer :callback_priority + t.datetime :enqueued_at + t.datetime :discarded_at + t.datetime :finished_at + t.datetime :jobs_finished_at + end + end + + # Create good_job_executions table if it doesn't exist + unless table_exists?(:good_job_executions) + create_table :good_job_executions, id: :uuid do |t| + t.timestamps + + t.uuid :active_job_id, null: false + t.text :job_class + t.text :queue_name + t.jsonb :serialized_params + t.datetime :scheduled_at + t.datetime :finished_at + t.text :error + t.integer :error_event, limit: 2 + t.text :error_backtrace, array: true + t.uuid :process_id + t.interval :duration + end + + add_index :good_job_executions, [:active_job_id, :created_at], name: :index_good_job_executions_on_active_job_id_and_created_at + add_index :good_job_executions, [:process_id, :created_at], name: :index_good_job_executions_on_process_id_and_created_at + end + + # Create good_job_processes table if it doesn't exist + unless table_exists?(:good_job_processes) + create_table :good_job_processes, id: :uuid do |t| + t.timestamps + t.jsonb :state + t.integer :lock_type, limit: 2 + end + end + + # Create good_job_settings table if it doesn't exist + unless table_exists?(:good_job_settings) + create_table :good_job_settings, id: :uuid do |t| + t.timestamps + t.text :key + t.jsonb :value + end + add_index :good_job_settings, :key, unique: true + end + end +end diff --git a/db/migrate/20251223000002_add_lock_type_to_good_job_processes.rb b/db/migrate/20251223000002_add_lock_type_to_good_job_processes.rb new file mode 100644 index 00000000000..e6a2df65ec6 --- /dev/null +++ b/db/migrate/20251223000002_add_lock_type_to_good_job_processes.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class AddLockTypeToGoodJobProcesses < ActiveRecord::Migration[7.2] + def change + return unless table_exists?(:good_job_processes) + return if column_exists?(:good_job_processes, :lock_type) + + add_column :good_job_processes, :lock_type, :integer, limit: 2 + end +end diff --git a/db/schema.rb b/db/schema.rb index 111bee2bf4c..94aff4b203f 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2025_09_11_162031) do +ActiveRecord::Schema[7.2].define(version: 2025_12_23_000002) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -430,6 +430,7 @@ t.index ["cron_key", "created_at"], name: "index_good_jobs_on_cron_key_and_created_at" t.index ["cron_key", "cron_at"], name: "index_good_jobs_on_cron_key_and_cron_at", unique: true t.index ["finished_at"], name: "index_good_jobs_jobs_on_finished_at", where: "((retried_good_job_id IS NULL) AND (finished_at IS NOT NULL))" + t.index ["job_class"], name: "index_good_jobs_on_job_class" t.index ["labels"], name: "index_good_jobs_on_labels", where: "(labels IS NOT NULL)", using: :gin t.index ["locked_by_id"], name: "index_good_jobs_on_locked_by_id", where: "(locked_by_id IS NOT NULL)" t.index ["priority", "created_at"], name: "index_good_jobs_jobs_on_priority_created_at_when_unfinished", order: { priority: "DESC NULLS LAST" }, where: "(finished_at IS NULL)" @@ -542,8 +543,9 @@ create_table "micro_reports", force: :cascade do |t| t.bigint "user_id", null: false t.text "content", null: false - t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.datetime "created_at", precision: 6, null: false + t.datetime "updated_at", precision: 6, null: false + t.bigint "comment_user_id" t.index ["user_id"], name: "index_micro_reports_on_user_id" end @@ -1068,6 +1070,7 @@ add_foreign_key "learning_times", "reports" add_foreign_key "linear_scales", "survey_questions" add_foreign_key "micro_reports", "users" + add_foreign_key "micro_reports", "users", column: "comment_user_id" add_foreign_key "movies", "users" add_foreign_key "notifications", "users" add_foreign_key "notifications", "users", column: "sender_id" diff --git a/test/components/users/micro_reports/micro_report_component_test.rb b/test/components/users/micro_reports/micro_report_component_test.rb index b9279aee218..403d3c28754 100644 --- a/test/components/users/micro_reports/micro_report_component_test.rb +++ b/test/components/users/micro_reports/micro_report_component_test.rb @@ -5,13 +5,15 @@ class Users::MicroReports::MicroReportComponentTest < ViewComponent::TestCase def setup @user = users(:hatsuno) + @other_user = users(:kimura) end def test_default micro_report = micro_reports(:hajime_first_micro_report) + micro_report.comment_user ||= micro_report.user render_component(micro_report) - assert_selector "img.micro-report_user-icon[title='#{micro_report.user.icon_title}']" + assert_selector "img.micro-report_user-icon[title='#{micro_report.comment_user.icon_title}']" assert_selector '.micro-report__body', text: micro_report.content assert_selector 'time.micro-report__created-at', text: I18n.l(micro_report.created_at, format: :date_and_time) assert_text '👍1' @@ -19,32 +21,57 @@ def test_default end def test_posted_datetime_today - micro_report = @user.micro_reports.create(content: '今日の分報', created_at: Time.zone.now) + micro_report = MicroReport.create!(user: @user, comment_user: @user, content: '今日の分報', created_at: Time.zone.now) render_component(micro_report) assert_includes page.text, "今日 #{I18n.l(Time.zone.now, format: :time_only)}" end def test_posted_datetime_yesterday - micro_report = @user.micro_reports.create(content: '昨日の分報', created_at: 1.day.ago) + micro_report = MicroReport.create!(user: @user, comment_user: @user, content: '昨日の分報', created_at: 1.day.ago) render_component(micro_report) assert_includes page.text, "昨日 #{I18n.l(1.day.ago, format: :time_only)}" end def test_posted_datetime_older_than_two_days - micro_report = @user.micro_reports.create(content: '2日前の分報', created_at: 2.days.ago) + micro_report = MicroReport.create!(user: @user, comment_user: @user, content: '2日前の分報', created_at: 2.days.ago) render_component(micro_report) assert_includes page.text, I18n.l(2.days.ago, format: :date_and_time) end + def test_comment_user_is_not_post_owner + micro_report = MicroReport.create!(user: @user, comment_user: @other_user, content: '他人のコメント') + render_component(micro_report) + + assert_selector "img.micro-report_user-icon[title='#{@other_user.icon_title}']" + assert_selector '.micro-report__body', text: '他人のコメント' + end + + def test_edit_delete_buttons_visible_only_to_comment_user_or_admin + micro_report = MicroReport.create!(user: @user, comment_user: @other_user, content: 'テスト') + + render_component(micro_report, current_user: @user) + assert_no_selector '.micro-report__footer .micro-report-actions .is-edit' + assert_no_selector '.micro-report__footer .micro-report-actions .is-delete' + + render_component(micro_report, current_user: @other_user) + assert_selector '.micro-report__footer .micro-report-actions .is-edit' + assert_selector '.micro-report__footer .micro-report-actions .is-delete' + + admin_user = users(:komagata) + render_component(micro_report, current_user: admin_user) + assert_selector '.micro-report__footer .micro-report-actions .is-edit' + assert_selector '.micro-report__footer .micro-report-actions .is-delete' + end + private - def render_component(micro_report) + def render_component(micro_report, current_user: @user) component = Users::MicroReports::MicroReportComponent.new( user: micro_report.user, - current_user: @user, + current_user:, micro_report: ) render_inline(component) diff --git a/test/fixtures/micro_reports.yml b/test/fixtures/micro_reports.yml index 6c1750fab9d..0eb89a297ef 100644 --- a/test/fixtures/micro_reports.yml +++ b/test/fixtures/micro_reports.yml @@ -1,13 +1,16 @@ hajime_first_micro_report: user: hajime + comment_user: hajime content: 最初の分報 created_at: <%= 2.day.ago %> hajime_second_micro_report: user: hajime + comment_user: hajime content: 2つ目の分報 created_at: <%= 1.day.ago %> hajime_third_micro_report: user: hajime + comment_user: hajime content: 最新の分報 diff --git a/test/integration/api/pub_sub_test.rb b/test/integration/api/pub_sub_test.rb new file mode 100644 index 00000000000..c6ed51ff8e1 --- /dev/null +++ b/test/integration/api/pub_sub_test.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require 'test_helper' +require 'ostruct' + +class API::PubSubControllerTest < ActionDispatch::IntegrationTest + setup do + @payload = { message: { data: Base64.encode64('dummy data') } } + + API::PubSubController.prepend(Module.new do + private + + def valid_pubsub_token?(_token) + true + end + end) + end + + test 'returns 200 when ProcessTranscodingNotification succeeds' do + ProcessTranscodingNotification.stub :call, OpenStruct.new(success?: true) do + post api_pubsub_path(format: :json), + params: @payload.to_json, + headers: { 'CONTENT_TYPE' => 'application/json', 'Authorization' => 'Bearer dummy' } + + assert_response :ok + end + end + + test 'returns 500 when ProcessTranscodingNotification fails and retryable' do + ProcessTranscodingNotification.stub :call, OpenStruct.new(success?: false, retryable: true, error: 'fail') do + post api_pubsub_path(format: :json), + params: @payload.to_json, + headers: { 'CONTENT_TYPE' => 'application/json', 'Authorization' => 'Bearer dummy' } + + assert_response :internal_server_error + end + end + + test 'returns 200 when ProcessTranscodingNotification fails but not retryable' do + ProcessTranscodingNotification.stub :call, OpenStruct.new(success?: false, retryable: false, error: 'fail') do + post api_pubsub_path(format: :json), + params: @payload.to_json, + headers: { 'CONTENT_TYPE' => 'application/json', 'Authorization' => 'Bearer dummy' } + + assert_response :ok + end + end +end diff --git a/test/interactors/process_transcoding_notification_test.rb b/test/interactors/process_transcoding_notification_test.rb new file mode 100644 index 00000000000..fe73b2f1fb5 --- /dev/null +++ b/test/interactors/process_transcoding_notification_test.rb @@ -0,0 +1,124 @@ +# frozen_string_literal: true + +require 'test_helper' + +class ProcessTranscodingNotificationTest < ActiveSupport::TestCase + def build_message(data) + { + 'message' => { + 'data' => Base64.encode64(data.to_json) + } + }.to_json + end + + setup do + @movie = movies(:movie1) + end + + test 'SUCCEEDED job calls attach_transcoded_file' do + body = build_message({ 'job' => { 'name' => 'job-1', 'state' => 'SUCCEEDED' } }) + interactor = ProcessTranscodingNotification.new(body:) + + attach_called = false + interactor.stub(:find_movie, @movie) do + interactor.stub(:attach_transcoded_file, ->(*) { attach_called = true }) do + interactor.call + end + end + + assert attach_called, 'attach_transcoded_file should be called for SUCCEEDED jobs' + end + + test 'job with missing name fails with retryable=false' do + body = build_message({ 'job' => { 'state' => 'SUCCEEDED' } }) + interactor = ProcessTranscodingNotification.new(body:) + + error = assert_raises(Interactor::Failure) { interactor.call } + failure_context = error.context + + assert_not(failure_context.retryable) + assert_equal 'Pub/Sub message missing required job fields: name=, state=SUCCEEDED', failure_context.error + end + + test 'job with unknown movie fails with retryable=false' do + body = build_message({ 'job' => { 'name' => 'unknown', 'state' => 'SUCCEEDED' } }) + interactor = ProcessTranscodingNotification.new(body:) + + error = assert_raises(Interactor::Failure) do + interactor.stub(:find_movie, nil) { interactor.call } + end + failure_context = error.context + + assert failure_context.failure? + assert_not failure_context.retryable + end + + test 'FAILED job with unexpected error is retryable' do + body = build_message({ + 'job' => { + 'name' => 'job-1', + 'state' => 'FAILED', + 'error' => { + 'details' => [{ 'fieldViolations' => [{ 'description' => 'Unexpected error' }] }] + } + } + }) + interactor = ProcessTranscodingNotification.new(body:) + + error = assert_raises(Interactor::Failure) { interactor.call } + failure_context = error.context + + assert failure_context.failure? + assert failure_context.retryable, 'Unexpected errors should be retryable' + end + + test 'invalid JSON fails with retryable=false' do + body = '{ invalid json' + interactor = ProcessTranscodingNotification.new(body:) + + error = assert_raises(Interactor::Failure) { interactor.call } + failure_context = error.context + + assert failure_context.failure? + assert_not failure_context.retryable + end + + test 'get_movie_id raises Google::Cloud::Error is retryable' do + body = build_message({ 'job' => { 'name' => 'job-1', 'state' => 'SUCCEEDED' } }) + interactor = ProcessTranscodingNotification.new(body:) + + error = assert_raises(Interactor::Failure) do + interactor.stub(:find_movie, ->(*) { raise Google::Cloud::Error, 'boom' }) do + interactor.call + end + end + failure_context = error.context + + assert failure_context.failure? + assert failure_context.retryable + end + + test 'FAILED job with AudioMissing error triggers TranscodeJob.perform_later' do + body = build_message({ + 'job' => { + 'name' => 'job-1', + 'state' => 'FAILED', + 'error' => { + 'details' => [{ 'fieldViolations' => [{ 'description' => 'AudioMissing' }] }] + } + } + }) + interactor = ProcessTranscodingNotification.new(body:) + + perform_later_called = false + interactor.stub(:find_movie, @movie) do + TranscodeJob.stub(:perform_later, ->(*) { perform_later_called = true }) do + interactor.call + rescue StandardError + nil + end + end + + assert perform_later_called, 'AudioMissing error should trigger TranscodeJob.perform_later' + end +end diff --git a/test/jobs/transcode_job_test.rb b/test/jobs/transcode_job_test.rb new file mode 100644 index 00000000000..74f3b7251b9 --- /dev/null +++ b/test/jobs/transcode_job_test.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +require 'test_helper' +require 'ostruct' +require 'google/cloud/errors' + +class TranscodeJobTest < ActiveJob::TestCase + setup do + @movie = movies(:movie1) + Rails.application.config.transcoder['enabled'] = true + end + + test 'calls transcode on Transcoder::Client' do + Transcoder::Client.stub :new, OpenStruct.new(transcode: nil) do + TranscodeJob.perform_now(@movie, force_video_only: true) + end + end + + test 'does not perform when transcoder is disabled' do + Rails.application.config.transcoder['enabled'] = false + begin + Transcoder::Client.stub :new, ->(*) { flunk 'should not be called' } do + TranscodeJob.perform_now(@movie) + end + ensure + Rails.application.config.transcoder['enabled'] = true + end + end + + test 'retries on retryable Google::Cloud::Error' do + error = Google::Cloud::Error.new('temporary failure') + def code = 429 + + job = TranscodeJob.new(@movie) + job.stub :executions, 0 do + job.stub :retryable?, true do + job.stub :retry_job, ->(wait:) { @retry_called = wait } do + job.send(:handle_transcode_error, error, @movie) + end + end + end + + assert @retry_called.present?, 'retry_job should be called' + end + + test 'logs on non-retryable error' do + error = StandardError.new('permanent failure') + + logged = nil + Rails.logger.stub :error, ->(msg) { logged = msg } do + TranscodeJob.new(@movie).send(:handle_transcode_error, error, @movie) + end + + assert_includes logged, 'permanent failure' + end +end diff --git a/test/models/micro_report_test.rb b/test/models/micro_report_test.rb new file mode 100644 index 00000000000..b935d61a220 --- /dev/null +++ b/test/models/micro_report_test.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +require 'test_helper' + +class MicroReportTest < ActiveSupport::TestCase + def setup + @user = users(:hatsuno) + end + + test 'comment_user is automatically set to user when not specified' do + user = users(:hatsuno) + micro_report = MicroReport.new(user:, content: 'テスト') + micro_report.save + assert_equal user, micro_report.comment_user + end + + test 'comment_user is not overwritten when already specified' do + user = users(:hatsuno) + other_user = users(:kimura) + micro_report = MicroReport.new(user:, comment_user: other_user, content: 'テスト') + micro_report.save + assert_equal other_user, micro_report.comment_user + end +end diff --git a/test/models/question_auto_closer_test.rb b/test/models/question_auto_closer_test.rb new file mode 100644 index 00000000000..05a3058a964 --- /dev/null +++ b/test/models/question_auto_closer_test.rb @@ -0,0 +1,135 @@ +# frozen_string_literal: true + +require 'test_helper' + +class QuestionAutoCloserTest < ActiveSupport::TestCase + AUTO_CLOSE_WARNING_MESSAGE = 'このQ&Aは1ヶ月間コメントがありませんでした。1週間後に自動的にクローズされます。' + AUTO_CLOSE_MESSAGE = '自動的にクローズしました。' + + test '.post_warning' do + created_at = Time.zone.local(2025, 10, 1, 0, 0, 0) + question = Question.create!( + title: '自動クローズテスト', + description: 'テスト', + user: users(:kimura), + created_at:, + updated_at: created_at + ) + + travel_to created_at.advance(months: 1, days: -1) do + assert_no_difference -> { question.answers.count } do + QuestionAutoCloser.post_warning + end + end + + travel_to created_at.advance(months: 1) do + assert_difference -> { question.answers.count }, 1 do + QuestionAutoCloser.post_warning + end + answer = question.answers.last + assert_equal users(:pjord), answer.user + assert_equal AUTO_CLOSE_WARNING_MESSAGE, answer.description + end + end + + test '.close_and_select_best_answer' do + created_at = Time.zone.local(2025, 10, 1, 0, 0, 0) + question = Question.create!( + title: '自動クローズテスト', + description: 'テスト', + user: users(:kimura), + created_at:, + updated_at: created_at + ) + + warned_at = created_at.advance(months: 1) + system_user = users(:pjord) + question.answers.create!( + user: system_user, + description: AUTO_CLOSE_WARNING_MESSAGE, + created_at: warned_at, + updated_at: warned_at + ) + + travel_to warned_at.advance(weeks: 1, days: -1) do + assert_no_difference -> { question.answers.count } do + QuestionAutoCloser.close_and_select_best_answer + end + end + + travel_to warned_at.advance(weeks: 1) do + assert_difference -> { question.answers.count }, 1 do + QuestionAutoCloser.close_and_select_best_answer + end + answer = question.answers.last + assert_equal system_user, answer.user + assert_equal AUTO_CLOSE_MESSAGE, answer.description + assert answer.is_a?(CorrectAnswer) + end + end + + test 'does not post warning for WIP questions' do + created_at = Time.zone.local(2025, 10, 1, 0, 0, 0) + question = Question.create!( + title: 'WIPの質問', + description: 'テスト', + user: users(:kimura), + wip: true, + created_at:, + updated_at: created_at + ) + + travel_to created_at.advance(months: 1) do + assert_no_difference -> { question.answers.count } do + QuestionAutoCloser.post_warning + end + end + end + + test 'does not close WIP questions' do + created_at = Time.zone.local(2025, 10, 1, 0, 0, 0) + question = Question.create!( + title: '公開から1ヶ月後WIPにする質問', + description: 'テスト', + user: users(:kimura), + created_at:, + updated_at: created_at + ) + + warned_at = created_at.advance(months: 1) + question.answers.create!( + user: users(:pjord), + description: AUTO_CLOSE_WARNING_MESSAGE, + created_at: warned_at, + updated_at: warned_at + ) + question.update!(wip: true, updated_at: warned_at) + + travel_to warned_at.advance(weeks: 1) do + assert_no_difference -> { question.answers.count } do + QuestionAutoCloser.close_and_select_best_answer + end + end + end + + test 'resets warning countdown when the question is updated' do + created_at = Time.zone.local(2025, 10, 1, 0, 0, 0) + question = Question.create!( + title: '後日公開する質問', + description: 'テスト', + user: users(:kimura), + wip: true, + created_at:, + updated_at: created_at + ) + + updated_at = created_at.advance(months: 1) + question.update!(wip: false, updated_at:) + + travel_to updated_at do + assert_no_difference -> { question.answers.count } do + QuestionAutoCloser.post_warning + end + end + end +end diff --git a/test/models/question_test.rb b/test/models/question_test.rb index 91941cdf960..0e3018aa387 100644 --- a/test/models/question_test.rb +++ b/test/models/question_test.rb @@ -135,56 +135,4 @@ class QuestionTest < ActiveSupport::TestCase assert_equal '質問を作成しました。', published_question.generate_notice_message(:create) assert_equal '質問を更新しました。', published_question.generate_notice_message(:update) end - - test '.post_warning' do - question_create_date = 2.months.ago.floor - - question = Question.create!( - title: '自動クローズテスト', - description: 'テスト', - user: users(:kimura), - created_at: question_create_date, - updated_at: question_create_date - ) - travel_to 1.month.ago + 1.day do - assert_difference -> { question.answers.count }, 1 do - QuestionAutoCloser.post_warning - end - answer = question.answers.last - assert_equal users(:pjord), answer.user - assert_includes answer.description, '1週間後に自動的にクローズされます' - end - end - - test '.close_and_select_best_answer' do - question = Question.create!( - title: '自動クローズテスト2', - description: 'テスト', - user: users(:kimura), - created_at: 2.months.ago, - updated_at: 2.months.ago - ) - - question.answers.create!( - user: users(:kimura), - description: 'これは通常のユーザーによる回答です', - created_at: 6.weeks.ago - ) - - system_user = users(:pjord) - question.answers.create!( - user: system_user, - description: 'このQ&Aは1ヶ月間コメントがありませんでした。1週間後に自動的にクローズされます。', - created_at: 8.days.ago - ) - - QuestionAutoCloser.close_and_select_best_answer - - question.reload - - correct_answer = CorrectAnswer.find_by(question_id: question.id) - assert_not_nil correct_answer - assert_equal system_user, correct_answer.user - assert_includes correct_answer.description, '自動的にクローズしました' - end end diff --git a/test/models/retirement_test.rb b/test/models/retirement_test.rb index fe7794c250e..de83cecac95 100644 --- a/test/models/retirement_test.rb +++ b/test/models/retirement_test.rb @@ -20,7 +20,7 @@ class RetirementTest < ActiveSupport::TestCase remove_as_event_organizer clear_github_info destroy_cards - publish_to_newspaper + publish notify ] diff --git a/test/models/times_channel_destroyer_test.rb b/test/models/times_channel_destroyer_test.rb index f22c25da96c..66d9df58ae0 100644 --- a/test/models/times_channel_destroyer_test.rb +++ b/test/models/times_channel_destroyer_test.rb @@ -12,7 +12,7 @@ class TimesChannelDestroyerTest < ActiveSupport::TestCase Rails.logger.stub(:warn, ->(message) { logs << message }) do Discord::Server.stub(:delete_text_channel, true) do - TimesChannelDestroyer.new.call({ user: }) + TimesChannelDestroyer.new.call(nil, nil, nil, nil, { user: }) end assert_nil user.discord_profile.times_id assert_nil user.discord_profile.times_url @@ -26,7 +26,7 @@ class TimesChannelDestroyerTest < ActiveSupport::TestCase user.discord_profile.update!(times_id: '987654321987654321') Rails.logger.stub(:warn, ->(message) { logs << message }) do Discord::Server.stub(:delete_text_channel, nil) do - TimesChannelDestroyer.new.call({ user: }) + TimesChannelDestroyer.new.call(nil, nil, nil, nil, { user: }) end assert_equal '987654321987654321', user.discord_profile.times_id assert_equal "[Discord API] #{user.login_name}の分報チャンネルが削除できませんでした。", logs.last diff --git a/test/models/transcoder/client_test.rb b/test/models/transcoder/client_test.rb new file mode 100644 index 00000000000..b0866a8ca26 --- /dev/null +++ b/test/models/transcoder/client_test.rb @@ -0,0 +1,118 @@ +# frozen_string_literal: true + +require 'test_helper' + +module Transcoder + class ClientTest < ActiveSupport::TestCase + setup do + @valid_movie = OpenStruct.new(id: 1, movie_data: OpenStruct.new(blob: OpenStruct.new(key: 'dummy.mp4'))) + @valid_config = { + 'location' => 'us-central1', + 'pubsub_topic' => 'topic', + 'video_height' => 720, + 'video_width' => 1280, + 'video_bitrate' => 1_000_000, + 'video_frame_rate' => 30, + 'audio_codec' => 'aac', + 'audio_bitrate' => 128_000, + 'container' => 'mp4' + } + end + + private + + def client(force_video_only: false) + Transcoder::Client.new(@valid_movie, config: @valid_config, bucket_name: 'bucket', project_id: 'proj', force_video_only:) + end + + def with_transcoder_service(client, list_jobs: [], create_job_proc: nil, get_job_proc: nil) + client.define_singleton_method(:transcoder_service) do + Object.new.tap do |service| + service.define_singleton_method(:list_jobs) { |*| list_jobs } + service.define_singleton_method(:create_job) { |**args| create_job_proc&.call(args) } + service.define_singleton_method(:get_job) { |**args| get_job_proc&.call(args) } + end + end + end + + test 'bucket_name required' do + error = assert_raises(ArgumentError) { Transcoder::Client.new(@valid_movie, config: @valid_config, bucket_name: nil, project_id: 'proj') } + assert_equal 'bucket_name is required', error.message + end + + test 'project_id required' do + error = assert_raises(ArgumentError) { Transcoder::Client.new(@valid_movie, config: @valid_config, bucket_name: 'bucket', project_id: nil) } + assert_equal 'project_id is required', error.message + end + + test 'location required in config' do + config = @valid_config.dup + config['location'] = nil + error = assert_raises(ArgumentError) { Transcoder::Client.new(@valid_movie, config:, bucket_name: 'bucket', project_id: 'proj') } + assert_equal 'location is required', error.message + end + + test 'pubsub_topic required in config' do + config = @valid_config.dup + config['pubsub_topic'] = nil + error = assert_raises(ArgumentError) { Transcoder::Client.new(@valid_movie, config:, bucket_name: 'bucket', project_id: 'proj') } + assert_equal 'pubsub_topic is required', error.message + end + + test '#transcode creates job' do + c = client + job_created = false + with_transcoder_service(c, list_jobs: [], create_job_proc: ->(_args) { job_created = true }) + c.transcode + assert job_created, 'A new job should be created when no existing job' + end + + test '#transcode skips if job exists' do + c = client + job_created = false + with_transcoder_service(c, list_jobs: [OpenStruct.new(labels: { 'movie_id' => @valid_movie.id.to_s }, state: 'RUNNING')], + create_job_proc: ->(_args) { job_created = true }) + c.transcode + assert_not job_created + end + + test '#transcode raises on failure' do + c = client + with_transcoder_service(c, list_jobs: [], create_job_proc: ->(_args) { raise Google::Cloud::Error, 'API failed' }) + error = assert_raises(Google::Cloud::Error) { c.transcode } + assert_equal 'API failed', error.message + end + + test '#transcode force_video_only' do + c = client(force_video_only: true) + created_job = nil + with_transcoder_service(c, list_jobs: [], create_job_proc: ->(args) { created_job = args[:job] }) + c.transcode + + elementary_keys = created_job[:config][:elementary_streams].map { |s| s[:key] } + assert_equal ['video-stream'], elementary_keys + + mux_keys = created_job[:config][:mux_streams].flat_map { |m| m[:elementary_streams] } + assert_not_includes mux_keys, 'audio-stream' + end + + test '#get_movie_id returns id' do + c = client + with_transcoder_service(c, get_job_proc: ->(_args) { OpenStruct.new(labels: { 'movie_id' => '123' }) }) + movie_id = c.get_movie_id('job_name_1') + assert_equal '123', movie_id + end + + test '#get_movie_id nil if blank' do + c = client + assert_nil c.get_movie_id(nil) + assert_nil c.get_movie_id('') + end + + test '#get_movie_id nil on error' do + c = client + with_transcoder_service(c, get_job_proc: ->(_args) { raise Google::Cloud::Error, 'boom' }) + assert_nil c.get_movie_id('job_name_1') + end + end +end diff --git a/test/models/transcoder/movie_test.rb b/test/models/transcoder/movie_test.rb new file mode 100644 index 00000000000..e10d5b6cb06 --- /dev/null +++ b/test/models/transcoder/movie_test.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +require 'test_helper' +require 'ostruct' +require 'google/cloud/storage' + +module Transcoder + class MovieTest < ActiveSupport::TestCase + def build_movie(file_exists: true, fail_gcs_delete: false, fail_gcs_download: false) + movie = movies(:movie1) + movie_obj = Transcoder::Movie.new(movie, bucket_name: 'dummy', path: 'dummy/path') + + movie_obj.define_singleton_method(:file) do + obj = Object.new + obj.define_singleton_method(:exists?) { file_exists } + obj.define_singleton_method(:delete) { raise Google::Cloud::Error, 'fail delete' if fail_gcs_delete } + obj.define_singleton_method(:download) do |path| + raise Google::Cloud::Error, 'fail download' if fail_gcs_download + + File.write(path, 'dummy data') + end + obj + end + + movie_obj + end + + test 'data returns tempfile' do + movie_obj = build_movie(file_exists: true) + tf = movie_obj.data + + assert tf.is_a?(Tempfile) + assert_equal 'dummy data', tf.read + end + + test 'data raises when file missing' do + movie_obj = build_movie(file_exists: false) + error = assert_raises(RuntimeError) { movie_obj.data } + assert_equal 'Transcoded file not found', error.message + end + + test 'data raises on GCS download error' do + movie_obj = build_movie(file_exists: true, fail_gcs_download: true) + error = assert_raises(Google::Cloud::Error) { movie_obj.data } + assert_equal 'fail download', error.message + end + + test 'cleanup deletes GCS file and tempfile' do + movie_obj = build_movie(file_exists: true) + temp = Tempfile.new('test') + path = temp.path + movie_obj.instance_variable_set(:@tempfile, temp) + + assert_silent { movie_obj.cleanup } + assert_not File.exist?(path) + end + + test 'cleanup ignores GCS delete errors' do + movie_obj = build_movie(file_exists: true, fail_gcs_delete: true) + movie_obj.instance_variable_set(:@tempfile, Tempfile.new('test')) + + assert_silent { movie_obj.cleanup } + end + + test 'cleanup ignores tempfile close errors' do + movie_obj = build_movie(file_exists: true) + temp = Tempfile.new('test') + movie_obj.instance_variable_set(:@tempfile, temp) + temp.define_singleton_method(:close!) { raise StandardError, 'fail' } + + assert_silent { movie_obj.cleanup } + end + end +end diff --git a/test/models/unfinished_data_destroyer_test.rb b/test/models/unfinished_data_destroyer_test.rb new file mode 100644 index 00000000000..36d8d5acd3c --- /dev/null +++ b/test/models/unfinished_data_destroyer_test.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +require 'test_helper' + +class UnfinishedDataDestroyerTest < ActiveSupport::TestCase + setup do + @payload = { user: users(:kimura) } + end + + test '#call deletes user wip reports and unchecked products and resets career_path' do + user = @payload[:user] + user.update!(career_path: 1) + + 3.times do |i| + Report.create!( + user:, + title: "wipの日報#{i}", + description: 'テスト日報', + wip: true, + reported_on: Date.current - i + ) + end + + practices = Practice.where.not(id: user.products.pluck(:practice_id)).take(3) + practices.each do |practice| + Product.create!( + user:, + practice:, + body: '提出物', + wip: false + ) + end + + UnfinishedDataDestroyer.new.call(nil, nil, nil, nil, @payload) + + assert_equal 0, Product.unchecked.where(user:).count + assert_equal 0, Report.wip.where(user:).count + assert_equal 'unset', user.reload.career_path + end + + test 'does not delete other users wip report and unchecked product' do + other_user_unchecked_product = products(:product1) + other_user_wip_report = reports(:report9) + + UnfinishedDataDestroyer.new.call(nil, nil, nil, nil, @payload) + + assert Product.exists?(other_user_unchecked_product.id) + assert Report.exists?(other_user_wip_report.id) + end + + test 'does not delete checked product or non-wip report' do + checked_product = products(:product2) + non_wip_report = reports(:report24) + + UnfinishedDataDestroyer.new.call(nil, nil, nil, nil, @payload) + + assert Product.exists?(checked_product.id) + assert Report.exists?(non_wip_report.id) + end +end diff --git a/test/queries/user_notifications_query_test.rb b/test/queries/user_notifications_query_test.rb new file mode 100644 index 00000000000..c0501c57541 --- /dev/null +++ b/test/queries/user_notifications_query_test.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +require 'test_helper' + +class UserNotificationsQueryTest < ActiveSupport::TestCase + test 'should return notifications for the given user' do + user = users(:komagata) + other_user = users(:machida) + + Notification.create!(user:, sender: other_user, kind: :came_comment, link: '/comments/1') + Notification.create!(user: other_user, sender: user, kind: :came_comment, link: '/comments/2') + + result = UserNotificationsQuery.new(user:, target: nil, status: nil).call + + assert_includes result.map(&:user), user + assert_not_includes result.map(&:user), other_user + end + + test 'should return unread notifications when status is unread' do + user = users(:komagata) + sender = users(:machida) + + read_notification = Notification.create!(user:, sender:, kind: :checked, read: true, link: '/checks/1') + unread_notification = Notification.create!(user:, sender:, kind: :checked, read: false, link: '/checks/2') + + result = UserNotificationsQuery.new(user:, target: 'check', status: 'unread').call + + assert_includes result, unread_notification + assert_not_includes result, read_notification + end + + test 'should return notifications for given target' do + user = users(:komagata) + sender = users(:machida) + + comment_notification = Notification.create!(user:, sender:, kind: :came_comment, link: '/comments/1') + check_notification = Notification.create!(user:, sender:, kind: :checked, link: '/checks/1') + + result = UserNotificationsQuery.new(user:, target: 'check', status: 'unread').call + + assert_includes result, check_notification + assert_not_includes result, comment_notification + end +end diff --git a/test/system/bookmark/talks_test.rb b/test/system/bookmark/talks_test.rb index 43aa50aee15..69de2e18c2f 100644 --- a/test/system/bookmark/talks_test.rb +++ b/test/system/bookmark/talks_test.rb @@ -11,6 +11,8 @@ class Bookmark::TalkTest < ApplicationSystemTestCase test 'show talk bookmark on lists' do visit_with_auth '/current_user/bookmarks', 'komagata' + find_all('a.pagination__item-link', text: '2').first.click if !page.has_text?("#{@decorated_user.long_name} さんの相談部屋") + assert_text "#{@decorated_user.long_name} さんの相談部屋" end diff --git a/test/system/current_user/bookmarks_test.rb b/test/system/current_user/bookmarks_test.rb index e8b56d01d97..b702c462b1d 100644 --- a/test/system/current_user/bookmarks_test.rb +++ b/test/system/current_user/bookmarks_test.rb @@ -26,7 +26,7 @@ class CurrentUser::BookmarksTest < ApplicationSystemTestCase visit_with_auth '/current_user/bookmarks', 'kimura' assert_selector 'label', text: '編集' - assert_selector 'input#card-list-tools__action', visible: false + assert_selector 'input#bookmark_edit', visible: false assert_text '作業週1日目' assert_selector '.card-list-item__label', text: '日報' @@ -104,8 +104,7 @@ class CurrentUser::BookmarksTest < ApplicationSystemTestCase user_with_some_bookmarks.bookmarks.create!(bookmarkable_id: reports("report#{n}".to_sym).id, bookmarkable_type: 'Report') end visit_with_auth '/current_user/bookmarks', user_with_some_bookmarks.login_name - # ページ遷移直後なのでreactコンポーネントが表示されるまで待つ - within "[data-testid='bookmarks']" do + within '.page-main' do assert_no_selector 'nav.pagination' end end @@ -127,8 +126,7 @@ class CurrentUser::BookmarksTest < ApplicationSystemTestCase user_with_many_bookmarks.bookmarks.create!(bookmarkable_id: reports("report#{n}".to_sym).id, bookmarkable_type: 'Report') end visit_with_auth '/current_user/bookmarks', user_with_many_bookmarks.login_name - # ページ遷移直後なのでreactコンポーネントが表示されるまで待つ - within "[data-testid='bookmarks']" do + within '.page-main' do assert_selector 'nav.pagination', count: 2 end diff --git a/test/system/notifications/pagination_test.rb b/test/system/notifications/pagination_test.rb index 075f4a6b667..97cb7aa90a0 100644 --- a/test/system/notifications/pagination_test.rb +++ b/test/system/notifications/pagination_test.rb @@ -24,7 +24,7 @@ class Notifications::PaginationTest < ApplicationSystemTestCase login_user 'mentormentaro', 'testtest' visit '/notifications' within first('nav.pagination') do - find('button', text: '2').click + click_link_or_button '2' end assert_text '1番古い通知' assert_no_text '1番新しい通知' @@ -55,14 +55,14 @@ class Notifications::PaginationTest < ApplicationSystemTestCase login_user 'mentormentaro', 'testtest' visit '/notifications?status=unread' within first('nav.pagination') do - find('button', text: '2').click + click_link_or_button '2' end assert_text '1番古い通知' assert_no_text '1番新しい通知' all('.pagination .is-active').each do |active_button| assert active_button.has_text? '2' end - assert_current_path('/notifications?status=unread&page=2') + assert_match %r{/notifications\?(status=unread&page=2|page=2&status=unread)}, current_url end test 'click on the pager button with multiple query string' do @@ -72,14 +72,14 @@ class Notifications::PaginationTest < ApplicationSystemTestCase login_user 'mentormentaro', 'testtest' visit '/notifications?status=unread&target=mention' within first('nav.pagination') do - find('button', text: '2').click + click_link_or_button '2' end assert_text '1番古い通知' assert_no_text '1番新しい通知' all('.pagination .is-active').each do |active_button| assert active_button.has_text? '2' end - assert_current_path('/notifications?status=unread&target=mention&page=2') + assert_match %r{/notifications\?(status=unread&page=2|page=2&status=unread)}, current_url end test 'specify the page number in the URL' do @@ -102,7 +102,7 @@ class Notifications::PaginationTest < ApplicationSystemTestCase login_user 'mentormentaro', 'testtest' visit '/notifications?page=2' within first('nav.pagination') do - find('button', text: '1').click + click_link_or_button '1' end assert_text '1番新しい通知' page.go_back diff --git a/test/system/user/micro_reports_test.rb b/test/system/user/micro_reports_test.rb index 6f9fd503ef2..54170acd366 100644 --- a/test/system/user/micro_reports_test.rb +++ b/test/system/user/micro_reports_test.rb @@ -29,12 +29,6 @@ class MicroReportsTest < ApplicationSystemTestCase end end - test 'form not found in other user microo reports page' do - visit_with_auth user_micro_reports_path(users(:hajime)), 'hatsuno' - assert has_no_field?(id: 'js-micro-report-textarea') - assert_no_button '投稿' - end - test 'form found in current user micro reports page' do visit_with_auth user_micro_reports_path(users(:hatsuno)), 'hatsuno' assert has_field?(id: 'js-micro-report-textarea') @@ -151,6 +145,24 @@ class MicroReportsTest < ApplicationSystemTestCase within(".micro-report#micro_report_#{micro_report.id}") { assert_no_selector 'a', text: '削除する' } end + test 'comment_user can delete their own comment on another user micro_report' do + user = users(:hatsuno) + comment_user = users(:kimura) + + micro_report = user.micro_reports.create!(content: '他人の分報にコメント', comment_user:) + + visit_with_auth user_micro_reports_path(user), 'kimura' + + within(".micro-report#micro_report_#{micro_report.id}") do + assert_selector 'a', text: '削除する' + click_link_or_button '削除する' + page.accept_alert + end + + assert_text '分報を削除しました。' + assert_no_text '他人の分報にコメント' + end + test 'update micro_report through comment tab form' do micro_report = micro_reports(:hajime_first_micro_report) visit_with_auth user_micro_reports_path(users(:hajime)), 'hajime'