diff --git a/CLAUDE.md b/CLAUDE.md index 70c87e59..0aa3d722 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,6 +22,12 @@ docker compose exec back bundle exec rails db:seed # シードデータ投入 docker compose exec back bundle exec rails routes # ルーティング確認 ``` +### マイグレーションを戻すとき + +- **`rails db:schema:load` は開発環境のデータを全て削除する**(`schema.rb` の `force: :cascade` で全テーブルがdrop&再作成される)。マイグレーションを1つ戻したいだけの場合には絶対に使わない +- 特定のマイグレーションだけを戻したい場合は `rails db:rollback` または `rails db:migrate:down VERSION=xxxxx` を使う。これなら他のテーブル・データには影響しない +- データを削除しうるDB操作(`schema:load`、`db:reset`、`db:drop` 等)を実行する前は、必ず内容を説明してユーザーに確認する + ## テスト ```bash diff --git a/app/controllers/api/v1/pro/sync_controller.rb b/app/controllers/api/v1/pro/sync_controller.rb index 811ab81e..825e432c 100644 --- a/app/controllers/api/v1/pro/sync_controller.rb +++ b/app/controllers/api/v1/pro/sync_controller.rb @@ -2,21 +2,23 @@ module Api module V1 module Pro # POST /api/v1/pro/sync - # クライアントから「RevenueCat と Rails の状態を再同期してくれ」と要求する暫定エンドポイント。 - # 本実装は #318 で RevenueCat REST API を叩いて状態を取得・反映する形に差し替える予定。 - # 本 Issue では last_synced_at の刻印と現在状態の返却だけを行うスタブとする。 + # クライアントから「RevenueCat と Rails の状態を再同期してくれ」と要求されたときに、 + # RevenueCat REST API から現在のsubscriber状態を取得してSubscriptionへ反映する。 + # Webhookの取りこぼし・配信遅延時に、クライアント側の「同期更新」ボタンから叩く想定。 class SyncController < ApplicationController before_action :authenticate_api_v1_user! def create user = current_api_v1_user - subscription = user.subscription || user.create_subscription!(status: 'free') - subscription.update!(last_synced_at: Time.current) + subscription = RevenueCat::SubscriberSync.new(user).call render json: { subscription: ::V1::SubscriptionSerializer.new(subscription).as_json, entitlements: ::Entitlement::ALL_FEATURES.select { |key| user.has_entitlement?(key) } }, status: :ok + rescue RevenueCat::SubscriberClient::RequestFailedError => e + Sentry.capture_exception(e, tags: { source: 'pro_sync_controller' }) + render json: { error: 'revenuecat_api_error' }, status: :bad_gateway end end end diff --git a/app/controllers/api/v2/goal_badges_controller.rb b/app/controllers/api/v2/goal_badges_controller.rb new file mode 100644 index 00000000..e0b1549b --- /dev/null +++ b/app/controllers/api/v2/goal_badges_controller.rb @@ -0,0 +1,13 @@ +module Api + module V2 + # 目標達成バッジの閲覧。付与はFinalizeGoalsJobが行い、ここは一覧取得のみ。 + class GoalBadgesController < Api::V2::ApplicationController + before_action :authenticate_api_v1_user! + + def index + badges = current_api_v1_user.goal_badges.includes(:goal).order(awarded_at: :desc) + render json: badges, each_serializer: ::V2::GoalBadgeSerializer, status: :ok + end + end + end +end diff --git a/app/controllers/api/v2/goals_controller.rb b/app/controllers/api/v2/goals_controller.rb index a996acc7..06d5b594 100644 --- a/app/controllers/api/v2/goals_controller.rb +++ b/app/controllers/api/v2/goals_controller.rb @@ -77,11 +77,11 @@ def goal_params :custom_metric_label, :custom_unit, :manual_current_value) end - # 更新では種類(period_type / season_id)を変更させない。 + # 更新では種類(period_type / season_id)と指標(metric_key / comparison_type / practice_menu_id)を変更させない。 + # 指標を差し替えると既存の target_value / manual_current_value が新指標に対して無意味になるため。 def update_params params.require(:goal).permit(:title, :month_start, :deadline, - :metric_key, :target_value, :comparison_type, :practice_menu_id, - :custom_metric_label, :custom_unit, :manual_current_value) + :target_value, :custom_metric_label, :custom_unit, :manual_current_value) end end end diff --git a/app/controllers/api/v2/plans_controller.rb b/app/controllers/api/v2/plans_controller.rb index 9586a141..a99d94ff 100644 --- a/app/controllers/api/v2/plans_controller.rb +++ b/app/controllers/api/v2/plans_controller.rb @@ -6,6 +6,9 @@ module V2 class PlansController < Api::V2::ApplicationController before_action :authenticate_api_v1_user! + # 無料ユーザーのカレンダー俯瞰は「直近月中心」に閲覧範囲を絞る(前後3ヶ月)。 + FREE_CALENDAR_WINDOW_MONTHS = 3 + def by_date date = parse_date(params[:date]) return render json: { error: 'date が不正です' }, status: :unprocessable_entity if date.nil? @@ -21,6 +24,12 @@ def calendar to = parse_date(params[:to]) return render json: { error: 'from / to が不正です' }, status: :unprocessable_entity if from.nil? || to.nil? || to < from + unless current_api_v1_user.has_entitlement?('schedule_calendar_full_history') + today = Time.find_zone('Asia/Tokyo').today + from = [from, today - FREE_CALENDAR_WINDOW_MONTHS.months].max + to = [to, today + FREE_CALENDAR_WINDOW_MONTHS.months].min + end + entries = (from..to).flat_map do |date| plans_on(date).map do |schedule| { date: date.iso8601, event_type: schedule.event_type, title: schedule.display_title, schedule_id: schedule.id } diff --git a/app/controllers/api/v2/stats_controller.rb b/app/controllers/api/v2/stats_controller.rb index 54a73c7c..73ce8e81 100644 --- a/app/controllers/api/v2/stats_controller.rb +++ b/app/controllers/api/v2/stats_controller.rb @@ -31,7 +31,14 @@ def pitching end def era_trend - render json: { trend: Stats::EraTrendService.new(**aggregator_params.except(:match_type)).call } + # シーズン粒度(シーズン跨ぎ推移)は Pro 限定。既存の月別は無料据え置き。 + if params[:granularity].to_s == 'season' && !current_api_v1_user.has_entitlement?('season_transition_graph') + return render json: { error: 'シーズン推移は Pro プラン限定です' }, status: :forbidden + end + + render json: Stats::EraTrendService.new( + **season_aware_params.except(:match_type), granularity: params[:granularity] + ).call end def game_summary @@ -69,7 +76,7 @@ def batting_trend end render json: Stats::BattingTrendAggregator.new( - **aggregator_params, granularity: params[:granularity] + **season_aware_params, granularity: params[:granularity] ).call end @@ -109,6 +116,15 @@ def aggregator_params } end + # granularity=season はシーズン跨ぎで全シーズンを比較する機能のため、 + # season_id による単一シーズン絞り込みと併用すると1シーズンに縮退してしまう。 + # season 粒度選択時は season_id を無視する(batting_trend / era_trend 共通)。 + def season_aware_params + return aggregator_params unless params[:granularity].to_s == 'season' + + aggregator_params.merge(season_id: nil) + end + # batting / pitching テーブル用は period (mode) を追加で受け取り、 # match_type は使わない(テーブルサービスのインターフェースに合わせる)。 def table_params diff --git a/app/models/concerns/entitlement.rb b/app/models/concerns/entitlement.rb index bd9ce48a..e96320d2 100644 --- a/app/models/concerns/entitlement.rb +++ b/app/models/concerns/entitlement.rb @@ -43,6 +43,8 @@ module Entitlement 'manual_metric_goals', # 自由指標(手動更新)の目標設定(無料は利用不可) 'shadow_swing_custom_interval', # 素振りカウンターのインターバル自由設定(無料は5〜10秒のみ) 'shadow_swing_vibration', # 素振りカウンターのバイブレーション設定(無料は利用不可) + 'shadow_swing_background', # 素振りカウンターのバックグラウンド継続実行(無料はバックグラウンド遷移で一時停止) + 'schedule_calendar_full_history', # カレンダー俯瞰の全期間閲覧(無料は直近月中心) 'unlimited_groups', # グループ作成・参加を無制限に(無料は所属1件まで) 'hit_direction_average', # 方向別の打率(打球方向ごとのヒートマップ) 'count_situation_average', # カウント別の打率(初球・有利・追い込み等) diff --git a/app/serializers/v2/goal_badge_serializer.rb b/app/serializers/v2/goal_badge_serializer.rb new file mode 100644 index 00000000..5af3e751 --- /dev/null +++ b/app/serializers/v2/goal_badge_serializer.rb @@ -0,0 +1,9 @@ +module V2 + class GoalBadgeSerializer < ActiveModel::Serializer + attributes :id, :badge_type, :badge_name, :awarded_at, :goal_id, :goal_title + + def goal_title + object.goal.title + end + end +end diff --git a/app/services/insights/correlation_builder.rb b/app/services/insights/correlation_builder.rb index 01286a6d..1a469535 100644 --- a/app/services/insights/correlation_builder.rb +++ b/app/services/insights/correlation_builder.rb @@ -41,6 +41,9 @@ def custom_card(combination) def build_card(spec) paired = paired_weeks(spec[:input_series], spec[:metric_series]) return insufficient_card(spec, paired.size) if paired.size < MIN_PAIRED_WEEKS + # 入力値に変動が無い(例: 素振りを一度もしていないユーザーは全週 0)場合、 + # 上位群/下位群への分割自体に意味が無く、出てくる差は入力とは無関係なノイズになる。 + return insufficient_card(spec, paired.size) if paired.pluck(:input).uniq.size < 2 low, high = split_by_input_median(paired) diff = mean(high.pluck(:metric)) - mean(low.pluck(:metric)) @@ -161,7 +164,11 @@ def accumulate_activity_inputs(inputs) logs = @user.activity_logs.where(activity_date: window_start..) logs.group_by { |log| log.activity_date.beginning_of_week }.each do |week_start, week_logs| inputs[week_start][:total_swings] = week_logs.sum(&:total_swing_count) - inputs[week_start][:practice_days] = week_logs.count { |log| log.intensity_level >= 1 } + # intensity_level は試合のみの日も L4 として含む(草・Streak向けの定義)ため、 + # ここでの「練習した日数」には使えない。練習メニュー実施 or 素振りがあった日だけを数える。 + inputs[week_start][:practice_days] = week_logs.count do |log| + log.practice_menu_count.positive? || log.total_swing_count.positive? + end end end diff --git a/app/services/revenue_cat/subscriber_client.rb b/app/services/revenue_cat/subscriber_client.rb new file mode 100644 index 00000000..8153616a --- /dev/null +++ b/app/services/revenue_cat/subscriber_client.rb @@ -0,0 +1,54 @@ +require 'net/http' +require 'uri' +require 'json' + +module RevenueCat + # RevenueCat REST API (GET /v1/subscribers/{app_user_id}) を叩く薄いクライアント。 + # サーバーサイド専用の secret key を使う(mobile側のSDK公開keyとは別物)。 + class SubscriberClient + BASE_URL = 'https://api.revenuecat.com/v1'.freeze + TIMEOUT_SECONDS = 5 + + class RequestFailedError < StandardError; end + + # タイムアウト・接続断・TLSエラー等はRequestFailedErrorへ変換し、 + # 呼び出し側(SyncController)が一律 bad_gateway として扱えるようにする。 + NETWORK_ERRORS = [ + Timeout::Error, Errno::ECONNREFUSED, Errno::ECONNRESET, SocketError, OpenSSL::SSL::SSLError, + Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout, Net::HTTPBadResponse, Net::HTTPHeaderSyntaxError, + Net::ProtocolError + ].freeze + + # @param app_user_id [String] RevenueCatのapp_user_id(このアプリではuser.id.to_sを使う) + # @return [Hash] `subscriber` 配下のHash + def self.fetch_subscriber(app_user_id) + new.fetch_subscriber(app_user_id) + end + + def fetch_subscriber(app_user_id) + uri = URI("#{BASE_URL}/subscribers/#{app_user_id}") + request = Net::HTTP::Get.new(uri) + request['Authorization'] = "Bearer #{secret_key}" + request['Content-Type'] = 'application/json' + + response = Net::HTTP.start(uri.host, uri.port, + use_ssl: true, open_timeout: TIMEOUT_SECONDS, read_timeout: TIMEOUT_SECONDS) do |http| + http.request(request) + end + + raise RequestFailedError, "RevenueCat API returned #{response.code}" unless response.is_a?(Net::HTTPSuccess) + + JSON.parse(response.body)['subscriber'] || {} + rescue *NETWORK_ERRORS => e + raise RequestFailedError, "RevenueCat API request failed: #{e.message}" + end + + private + + # デフォルト値を持たせない意図: 秘密鍵の設定漏れは一過性のネットワーク障害ではなく + # デプロイ設定のミスのため、502(RequestFailedError)で揉み消さずKeyErrorで即座に気付けるようにする。 + def secret_key + ENV.fetch('REVENUECAT_SECRET_API_KEY') + end + end +end diff --git a/app/services/revenue_cat/subscriber_sync.rb b/app/services/revenue_cat/subscriber_sync.rb new file mode 100644 index 00000000..25e7e025 --- /dev/null +++ b/app/services/revenue_cat/subscriber_sync.rb @@ -0,0 +1,105 @@ +module RevenueCat + # RevenueCat REST API から取得した現在のsubscriber状態でSubscriptionを上書きする。 + # Webhookの取りこぼし・遅延時に、クライアントの「同期更新」操作から明示的に叩く用途。 + # Webhook Handlerと違い単発の状態確定処理のため、通知Jobの発火は行わない + # (「今の状態を映すだけ」であり、ユーザーへ再度イベント通知すべきではないため)。 + class SubscriberSync + ENTITLEMENT_ID = 'pro'.freeze + + def initialize(user) + @user = user + end + + # @return [Subscription] 更新後のSubscription + def call + subscriber = SubscriberClient.fetch_subscriber(@user.id.to_s) + entitlement = subscriber.dig('entitlements', ENTITLEMENT_ID) + subscription = @user.subscription || @user.create_subscription!(status: 'free') + + if entitlement + apply_entitlement(subscription, subscriber, entitlement) + else + apply_no_entitlement(subscription) + end + + subscription + end + + private + + def apply_entitlement(subscription, subscriber, entitlement) + product_id = entitlement['product_identifier'] + subscription_detail = subscriber.dig('subscriptions', product_id) || {} + store = subscription_detail['store'].to_s.upcase + return if unknown_product?(product_id, store) + + subscription.update!(attributes_for(subscription, product_id, subscription_detail, entitlement)) + end + + # PlanCatalogに未登録のproduct_id/storeが来た場合、plan_type/platformにnilを + # 静かに保存してしまうとWebhookのHandler群(unknown_product?で更新自体をスキップする) + # と挙動が非対称になる。Webhookと同様に更新を丸ごとスキップしSentryへ警告する。 + def unknown_product?(product_id, store) + plan_type_missing = PlanCatalog.plan_type_from(product_id).nil? + platform_missing = PlanCatalog.platform_from(store).nil? + return false unless plan_type_missing || platform_missing + + Sentry.capture_message( + "RevenueCat sync: unknown product_id=#{product_id.inspect} or store=#{store.inspect}", + level: :warning + ) + true + end + + def attributes_for(subscription, product_id, subscription_detail, entitlement) + expires_at = parse_time(entitlement['expires_date']) + effective_expires_at = grace_expires_at(entitlement, subscription_detail) || expires_at + is_trial = subscription_detail['period_type'] == 'TRIAL' + + { + status: status_for(subscription_detail, effective_expires_at, is_trial), + plan_type: PlanCatalog.plan_type_from(product_id), + platform: PlanCatalog.platform_from(subscription_detail['store'].to_s.upcase), + product_id:, + started_at: parse_time(entitlement['purchase_date']) || subscription.started_at, + # Subscription#pro_active? / #in_grace_period? はこのカラムで期限内かを判定するため、 + # グレース期間中はグレース期限を保存する(webhook Handlerと同様、グレース中はPro機能を維持する)。 + expires_at: effective_expires_at, + cancelled_at: parse_time(subscription_detail['unsubscribe_detected_at']) || subscription.cancelled_at, + refunded_at: parse_time(subscription_detail['refunded_at']) || subscription.refunded_at, + billing_issue_at: parse_time(subscription_detail['billing_issues_detected_at']) || subscription.billing_issue_at, + has_used_trial: subscription.has_used_trial || is_trial, + revenuecat_user_id: @user.id.to_s, + revenuecat_entitlement_id: ENTITLEMENT_ID, + last_synced_at: Time.current + } + end + + def grace_expires_at(entitlement, subscription_detail) + parse_time(entitlement['grace_period_expires_date'] || subscription_detail['grace_period_expires_date']) + end + + # entitlementが存在しない = 一度も加入していないか、期限切れでRevenueCat側から外れた状態。 + def apply_no_entitlement(subscription) + new_status = subscription.has_used_trial || subscription.product_id.present? ? 'expired' : 'free' + subscription.update!(status: new_status, last_synced_at: Time.current) + end + + def status_for(subscription_detail, effective_expires_at, is_trial) + return 'expired' if effective_expires_at.present? && effective_expires_at <= Time.current + return 'billing_issue' if subscription_detail['billing_issues_detected_at'].present? + return 'cancelled' if subscription_detail['unsubscribe_detected_at'].present? + return 'trial' if is_trial + + 'active' + end + + def parse_time(value) + return nil if value.blank? + + Time.zone.parse(value.to_s) + rescue ArgumentError + nil + end + end +end diff --git a/app/services/stats/era_trend_service.rb b/app/services/stats/era_trend_service.rb index fd5b83df..5b9f64c1 100644 --- a/app/services/stats/era_trend_service.rb +++ b/app/services/stats/era_trend_service.rb @@ -1,9 +1,18 @@ # frozen_string_literal: true module Stats + # 防御率推移グラフ用 Service。 + # 月単位 (granularity=month、デフォルト) もしくはシーズン単位 (granularity=season) で + # 時系列集計し、各時点のERAを返す。ERA は + # `SUM(earned_run × match_results.inning_format) / SUM(innings_pitched)` で算出し、 + # 7回制/9回制が混在しても各試合のイニング制を加重した値で計算する。 class EraTrendService - def initialize(user_id:, year: nil, season_id: nil, tournament_id: nil, start_month: nil, end_month: nil) + SUPPORTED_GRANULARITIES = %w[month season].freeze + + def initialize(user_id:, granularity: 'month', + year: nil, season_id: nil, tournament_id: nil, start_month: nil, end_month: nil) @user_id = user_id + @granularity = SUPPORTED_GRANULARITIES.include?(granularity.to_s) ? granularity.to_s : 'month' @year = year @season_id = season_id @tournament_id = tournament_id @@ -11,34 +20,64 @@ def initialize(user_id:, year: nil, season_id: nil, tournament_id: nil, start_mo @end_month = end_month end - # 月別の防御率推移を返す。 - # ERA は `SUM(earned_run × match_results.inning_format) / SUM(innings_pitched)` で算出し、 - # 7回制/9回制が混在しても各試合のイニング制を加重した値で計算する。 - # @return [ArrayNumeric}>] [{ month: Integer, era: Float }, ...] + # @return [Hash] granularity と points 配列。points は key / label / era を持つ。 def call + points = @granularity == 'season' ? aggregate_by_season : aggregate_by_month + { granularity: @granularity, points: } + end + + private + + def aggregate_by_month scope = base_scope return [] if scope.none? - # 月ごとに集計(earned_run には inning_format を係数として掛けて加重する) - monthly = scope - .select(Arel.sql( - "#{Stats::JstDateSql::MONTH_JST_INT_SQL} AS month, " \ - 'SUM(pitching_results.innings_pitched) AS total_ip, ' \ - 'SUM(pitching_results.earned_run * match_results.inning_format) AS total_weighted_er' - )) - .group(Arel.sql(Stats::JstDateSql::MONTH_JST_INT_SQL)) - .order(Arel.sql('month')) - - monthly.filter_map do |r| - ip = r.total_ip.to_f - next if ip <= 0 - - era = (r.total_weighted_er.to_f / ip).round(2) - { month: r.month, era: } + rows = scope + .select(Arel.sql( + "#{Stats::JstDateSql::MONTH_JST_INT_SQL} AS month, " \ + 'SUM(pitching_results.innings_pitched) AS total_ip, ' \ + 'SUM(pitching_results.earned_run * match_results.inning_format) AS total_weighted_er' + )) + .group(Arel.sql(Stats::JstDateSql::MONTH_JST_INT_SQL)) + .order(Arel.sql('month')) + + rows.filter_map do |r| + era = era_for(total_ip: r.total_ip, total_weighted_er: r.total_weighted_er) + next if era.nil? + + { key: format('month-%02d', month: r.month.to_i), label: "#{r.month}月", era: } end end - private + # シーズンごとのERAを返す(シーズン跨ぎ比較)。season_id が未割り当ての試合は集計対象外。 + def aggregate_by_season + scope = base_scope + return [] if scope.none? + + rows = scope + .joins('INNER JOIN seasons ON seasons.id = game_results.season_id') + .select(Arel.sql( + 'seasons.id AS season_id, seasons.name AS season_name, seasons.created_at AS season_created_at, ' \ + 'SUM(pitching_results.innings_pitched) AS total_ip, ' \ + 'SUM(pitching_results.earned_run * match_results.inning_format) AS total_weighted_er' + )) + .group('seasons.id, seasons.name, seasons.created_at') + .order(Arel.sql('seasons.created_at ASC')) + + rows.filter_map do |r| + era = era_for(total_ip: r.total_ip, total_weighted_er: r.total_weighted_er) + next if era.nil? + + { key: "season-#{r.season_id}", label: r.season_name, era: } + end + end + + def era_for(total_ip:, total_weighted_er:) + ip = total_ip.to_f + return nil if ip <= 0 + + (total_weighted_er.to_f / ip).round(2) + end def base_scope scope = PitchingResult.joins(game_result: :match_result) diff --git a/config/routes.rb b/config/routes.rb index 36b28a38..e2c13f42 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -294,6 +294,7 @@ collection { get :history } resource :achievement, only: %i[create destroy], controller: 'goals/achievements' end + resources :goal_badges, only: %i[index] resources :baseball_notes, only: %i[index show create update destroy] resources :improvement_themes, only: %i[index create update destroy] resources :reflection_templates, only: %i[index create update destroy] diff --git a/spec/factories/goal_badges.rb b/spec/factories/goal_badges.rb new file mode 100644 index 00000000..eaeeee1b --- /dev/null +++ b/spec/factories/goal_badges.rb @@ -0,0 +1,9 @@ +FactoryBot.define do + factory :goal_badge do + user + goal + badge_type { 'monthly_achieved' } + badge_name { '月間目標達成' } + awarded_at { Time.current } + end +end diff --git a/spec/models/concerns/entitlement_spec.rb b/spec/models/concerns/entitlement_spec.rb index 9591cc37..84f10c2e 100644 --- a/spec/models/concerns/entitlement_spec.rb +++ b/spec/models/concerns/entitlement_spec.rb @@ -4,11 +4,11 @@ let(:user) { create(:user) } describe 'feature key constants' do - it 'defines exactly 10 free features and 29 pro features' do + it 'defines exactly 10 free features and 31 pro features' do # %w[] とインラインコメントの混在で feature key が壊れる回帰を防ぐ expect(described_class::FREE_FEATURES.size).to eq 10 - expect(described_class::PRO_FEATURES.size).to eq 29 - expect(described_class::ALL_FEATURES.size).to eq 39 + expect(described_class::PRO_FEATURES.size).to eq 31 + expect(described_class::ALL_FEATURES.size).to eq 41 end it 'contains only valid feature key strings (no stray symbols)' do diff --git a/spec/requests/api/v1/pro_spec.rb b/spec/requests/api/v1/pro_spec.rb index c5f96566..33c0dd44 100644 --- a/spec/requests/api/v1/pro_spec.rb +++ b/spec/requests/api/v1/pro_spec.rb @@ -53,7 +53,9 @@ end context 'when authenticated' do - it 'updates last_synced_at and returns current state' do + it 'fetches the current RevenueCat subscriber state, updates last_synced_at, and returns it' do + allow(RevenueCat::SubscriberClient).to receive(:fetch_subscriber).with(user.id.to_s).and_return({}) + expect do post '/api/v1/pro/sync', headers: auth_headers_for(user) end.to change { user.subscription.reload.last_synced_at }.from(nil) @@ -63,6 +65,36 @@ expect(json['subscription']['status']).to eq 'free' expect(json['entitlements']).to be_an(Array) end + + it 'reflects an active RevenueCat entitlement into the subscription' do + allow(RevenueCat::SubscriberClient).to receive(:fetch_subscriber).with(user.id.to_s).and_return( + 'entitlements' => { + 'pro' => { + 'product_identifier' => 'buzzbase_pro_monthly', + 'purchase_date' => 1.day.ago.iso8601, + 'expires_date' => 29.days.from_now.iso8601 + } + }, + 'subscriptions' => { + 'buzzbase_pro_monthly' => { 'store' => 'app_store', 'period_type' => 'NORMAL' } + } + ) + + post '/api/v1/pro/sync', headers: auth_headers_for(user) + + expect(response).to have_http_status(:ok) + expect(response.parsed_body['subscription']['status']).to eq 'active' + end + + it 'returns bad_gateway when the RevenueCat API request fails' do + allow(RevenueCat::SubscriberClient).to receive(:fetch_subscriber) + .and_raise(RevenueCat::SubscriberClient::RequestFailedError, 'RevenueCat API returned 500') + + post '/api/v1/pro/sync', headers: auth_headers_for(user) + + expect(response).to have_http_status(:bad_gateway) + expect(response.parsed_body['error']).to eq 'revenuecat_api_error' + end end end diff --git a/spec/requests/api/v2/goal_badges_spec.rb b/spec/requests/api/v2/goal_badges_spec.rb new file mode 100644 index 00000000..73e5663f --- /dev/null +++ b/spec/requests/api/v2/goal_badges_spec.rb @@ -0,0 +1,41 @@ +require 'rails_helper' + +RSpec.describe 'Api::V2::GoalBadges', type: :request do + let(:user) { create(:user) } + let(:headers) { auth_headers_for(user) } + + describe 'GET /api/v2/goal_badges' do + it 'returns 401 when not authenticated' do + get '/api/v2/goal_badges' + expect(response).to have_http_status(:unauthorized) + end + + it '自分のバッジを新しい順で返す' do + goal = create(:goal, user:) + older = create(:goal_badge, user:, goal:, badge_name: '月間目標達成', awarded_at: 2.days.ago) + newer = create(:goal_badge, user:, goal:, badge_name: 'シーズン目標達成', awarded_at: 1.day.ago) + + get('/api/v2/goal_badges', headers:) + expect(response).to have_http_status(:ok) + badge_ids = response.parsed_body.pluck('id') + expect(badge_ids).to eq([newer.id, older.id]) + end + + it '他ユーザーのバッジは含まれない' do + other_user = create(:user) + other_goal = create(:goal, user: other_user) + create(:goal_badge, user: other_user, goal: other_goal) + + get('/api/v2/goal_badges', headers:) + expect(response.parsed_body).to eq([]) + end + + it 'goal_titleにバッジの元になった目標のタイトルを含む' do + goal = create(:goal, user:, title: '今月20日練習') + create(:goal_badge, user:, goal:) + + get('/api/v2/goal_badges', headers:) + expect(response.parsed_body.first['goal_title']).to eq('今月20日練習') + end + end +end diff --git a/spec/requests/api/v2/goals_spec.rb b/spec/requests/api/v2/goals_spec.rb index 6b6e1260..9f3f5051 100644 --- a/spec/requests/api/v2/goals_spec.rb +++ b/spec/requests/api/v2/goals_spec.rb @@ -168,6 +168,20 @@ def make_pro(target) expect(goal.reload.period_type).to eq('monthly') expect(goal.title).to eq('変更') end + + it '更新で指標(metric_key / comparison_type / practice_menu_id)は変更できない' do + goal = create(:goal, user:, metric_key: 'practice_days', comparison_type: 'greater_than', practice_menu_id: nil) + other_menu = create(:practice_menu, user:) + patch "/api/v2/goals/#{goal.id}", + params: { goal: { metric_key: 'batting_average', comparison_type: 'less_than', practice_menu_id: other_menu.id } }, + headers: auth_headers_for(user) + expect(response).to have_http_status(:ok) + aggregate_failures do + expect(goal.reload.metric_key).to eq('practice_days') + expect(goal.comparison_type).to eq('greater_than') + expect(goal.practice_menu_id).to be_nil + end + end end describe '定性目標(qualitative)' do diff --git a/spec/requests/api/v2/plans_spec.rb b/spec/requests/api/v2/plans_spec.rb index e8346966..ece4255c 100644 --- a/spec/requests/api/v2/plans_spec.rb +++ b/spec/requests/api/v2/plans_spec.rb @@ -81,6 +81,8 @@ end it '期間内の予定を日別エントリで返す' do + # 無料の閲覧範囲クランプ(直近月中心)の影響を受けないようにする。日付自体はテスト対象外。 + make_pro(user) create(:schedule, user:, title: '朝練', days_of_week: '1', scheduled_time: '06:00') create(:schedule, user:, title: '試合', days_of_week: nil, planned_on: '2026-07-08', event_type: 'game') @@ -92,5 +94,34 @@ game_entry = entries.find { |entry| entry['event_type'] == 'game' } expect(game_entry['title']).to eq('試合') end + + context '無料ユーザーの閲覧範囲(直近月中心)' do + it '前後3ヶ月を超える未来の予定はクランプされて含まれない' do + today = Time.zone.today + far_future = today + 4.months + create(:schedule, user:, title: '遠い未来の予定', days_of_week: nil, planned_on: far_future) + + get '/api/v2/plans/calendar', + params: { from: today.iso8601, to: (far_future + 1).iso8601 }, + headers: auth_headers_for(user) + + dates = response.parsed_body['entries'].pluck('date') + expect(dates).not_to include(far_future.iso8601) + end + + it 'schedule_calendar_full_historyを持つProユーザーはクランプされない' do + make_pro(user) + today = Time.zone.today + far_future = today + 4.months + create(:schedule, user:, title: '遠い未来の予定', days_of_week: nil, planned_on: far_future) + + get '/api/v2/plans/calendar', + params: { from: today.iso8601, to: (far_future + 1).iso8601 }, + headers: auth_headers_for(user) + + dates = response.parsed_body['entries'].pluck('date') + expect(dates).to include(far_future.iso8601) + end + end end end diff --git a/spec/requests/api/v2/stats_batting_trend_season_spec.rb b/spec/requests/api/v2/stats_batting_trend_season_spec.rb index 3a2a0d39..a8ac1b38 100644 --- a/spec/requests/api/v2/stats_batting_trend_season_spec.rb +++ b/spec/requests/api/v2/stats_batting_trend_season_spec.rb @@ -34,6 +34,22 @@ def make_pro(target) expect(body['granularity']).to eq('season') expect(body['points'].first['label']).to eq('2026春') end + + it '既存のseason_id絞り込みと併用しても全シーズンを返す(season_idは無視される)' do + season1 = create(:season, user:, name: '2026春') + season2 = create(:season, user:, name: '2026夏') + game1 = create(:game_result, user:, season: season1) + game2 = create(:game_result, user:, season: season2) + create(:batting_average, game_result: game1, user:, at_bats: 4, hit: 2, total_bases: 2) + create(:batting_average, game_result: game2, user:, at_bats: 4, hit: 1, total_bases: 1) + + get '/api/v2/stats/batting_trend', + params: { granularity: 'season', season_id: season1.id }, + headers: auth_headers_for(user) + expect(response).to have_http_status(:ok) + body = response.parsed_body + expect(body['points'].pluck('label')).to contain_exactly('2026春', '2026夏') + end end end end diff --git a/spec/requests/api/v2/stats_spec.rb b/spec/requests/api/v2/stats_spec.rb index d972b9da..23999e03 100644 --- a/spec/requests/api/v2/stats_spec.rb +++ b/spec/requests/api/v2/stats_spec.rb @@ -93,13 +93,39 @@ expect(response).to have_http_status(:unauthorized) end - it 'returns 200 with trend array' do + it 'returns 200 with granularity (default month) + points array' do get('/api/v2/stats/era_trend', headers:) expect(response).to have_http_status(:ok) json = response.parsed_body - expect(json['trend']).to be_an(Array) - expect(json['trend'].first).to include('month', 'era') if json['trend'].any? + expect(json['granularity']).to eq('month') + expect(json['points']).to be_an(Array) + expect(json['points'].first).to include('key', 'label', 'era') if json['points'].any? + end + + it 'returns 403 for granularity=season when the user is free' do + get('/api/v2/stats/era_trend', params: { granularity: 'season' }, headers:) + expect(response).to have_http_status(:forbidden) + end + + context 'Pro ユーザー' do + before { make_pro(user) } + + it 'returns season-granularity points for a Pro user' do + season1 = create(:season, user:, name: '2026春') + season2 = create(:season, user:, name: '2026夏') + game1 = create(:game_result, user:, season: season1) + game2 = create(:game_result, user:, season: season2) + create(:pitching_result, game_result: game1, user:, innings_pitched: 6, earned_run: 2) + create(:pitching_result, game_result: game2, user:, innings_pitched: 6, earned_run: 3) + + get('/api/v2/stats/era_trend', params: { granularity: 'season' }, headers:) + + expect(response).to have_http_status(:ok) + json = response.parsed_body + expect(json['granularity']).to eq('season') + expect(json['points'].pluck('label')).to contain_exactly('2026春', '2026夏') + end end end diff --git a/spec/services/insights/correlation_builder_spec.rb b/spec/services/insights/correlation_builder_spec.rb index ad77b479..18029fdb 100644 --- a/spec/services/insights/correlation_builder_spec.rb +++ b/spec/services/insights/correlation_builder_spec.rb @@ -56,5 +56,37 @@ def record_week(offset_weeks, swings:, at_bats:, hits:) expect(custom[:key]).to eq("custom_#{combo.id}") expect(custom[:title]).to eq('睡眠時間とOPS') end + + # 試合のみ記録し練習を一度もしていないユーザーが対象。 + # game_result 保存時に activity_log が作られるが、practice_menu_count/total_swing_count は + # 0 のまま、intensity_level だけ試合により L4 になる。 + def record_game_only_week(offset_weeks, at_bats:, hits:) + day = week_start(offset_weeks) + game = create(:game_result, user:) + game.match_result.update!(date_and_time: Time.utc(day.year, day.month, day.day, 12)) + create(:batting_average, user:, game_result: game, at_bats:, hit: hits, + two_base_hit: 0, three_base_hit: 0, home_run: 0, strike_out: 0, plate_appearances: at_bats) + end + + it '練習を一度もしていないユーザーには素振り本数のカードを断定表示しない(入力値が全週0で変動が無いため)' do + record_game_only_week(0, at_bats: 4, hits: 3) + record_game_only_week(1, at_bats: 4, hits: 2) + record_game_only_week(2, at_bats: 4, hits: 0) + record_game_only_week(3, at_bats: 4, hits: 0) + + swings_card = described_class.new(user:).call.find { |card| card[:key] == 'swings_vs_ba' } + expect(swings_card[:sufficient]).to be false + expect(swings_card[:direction]).to eq('unknown') + end + + it '試合のみの日は「練習した日数」に含めない' do + record_game_only_week(0, at_bats: 4, hits: 3) + record_game_only_week(1, at_bats: 4, hits: 2) + record_game_only_week(2, at_bats: 4, hits: 0) + record_game_only_week(3, at_bats: 4, hits: 0) + + practice_days_card = described_class.new(user:).call.find { |card| card[:key] == 'practice_days_vs_ops' } + expect(practice_days_card[:sufficient]).to be false + end end end diff --git a/spec/services/revenue_cat/subscriber_client_spec.rb b/spec/services/revenue_cat/subscriber_client_spec.rb new file mode 100644 index 00000000..eb504519 --- /dev/null +++ b/spec/services/revenue_cat/subscriber_client_spec.rb @@ -0,0 +1,39 @@ +require 'rails_helper' + +RSpec.describe RevenueCat::SubscriberClient do + before do + allow(ENV).to receive(:fetch).and_call_original + allow(ENV).to receive(:fetch).with('REVENUECAT_SECRET_API_KEY').and_return('sk_test_dummy') + end + + describe '#fetch_subscriber' do + it 'returns the subscriber Hash on success' do + response = instance_double(Net::HTTPOK, body: { subscriber: { 'entitlements' => {} } }.to_json) + allow(response).to receive(:is_a?).with(Net::HTTPSuccess).and_return(true) + allow(Net::HTTP).to receive(:start).and_return(response) + + result = described_class.new.fetch_subscriber('123') + expect(result).to eq({ 'entitlements' => {} }) + end + + it 'raises RequestFailedError on a non-success response' do + response = instance_double(Net::HTTPNotFound, code: '404') + allow(response).to receive(:is_a?).with(Net::HTTPSuccess).and_return(false) + allow(Net::HTTP).to receive(:start).and_return(response) + + expect { described_class.new.fetch_subscriber('123') }.to raise_error(described_class::RequestFailedError) + end + + it 'converts a read timeout into RequestFailedError instead of leaking a raw Net::ReadTimeout' do + allow(Net::HTTP).to receive(:start).and_raise(Net::ReadTimeout) + + expect { described_class.new.fetch_subscriber('123') }.to raise_error(described_class::RequestFailedError) + end + + it 'converts a connection refusal into RequestFailedError' do + allow(Net::HTTP).to receive(:start).and_raise(Errno::ECONNREFUSED) + + expect { described_class.new.fetch_subscriber('123') }.to raise_error(described_class::RequestFailedError) + end + end +end diff --git a/spec/services/revenue_cat/subscriber_sync_spec.rb b/spec/services/revenue_cat/subscriber_sync_spec.rb new file mode 100644 index 00000000..74591a2a --- /dev/null +++ b/spec/services/revenue_cat/subscriber_sync_spec.rb @@ -0,0 +1,134 @@ +require 'rails_helper' + +RSpec.describe RevenueCat::SubscriberSync do + let(:user) { create(:user) } + + def stub_subscriber(hash) + allow(RevenueCat::SubscriberClient).to receive(:fetch_subscriber).with(user.id.to_s).and_return(hash) + end + + # entitlement/subscription detail をマージした最小限のsubscriberレスポンスを組み立てる。 + def stub_entitlement(entitlement_overrides: {}, subscription_overrides: {}) + stub_subscriber( + 'entitlements' => { + 'pro' => { + 'product_identifier' => 'buzzbase_pro_monthly', + 'purchase_date' => 10.days.ago.iso8601, + 'expires_date' => 20.days.from_now.iso8601 + }.merge(entitlement_overrides) + }, + 'subscriptions' => { + 'buzzbase_pro_monthly' => { 'store' => 'app_store', 'period_type' => 'NORMAL' }.merge(subscription_overrides) + } + ) + end + + describe '#call' do + context 'entitlementが存在しない場合' do + it '一度も加入していなければ free のままにする' do + stub_subscriber({}) + subscription = described_class.new(user).call + expect(subscription.status).to eq('free') + end + + it '過去に加入していれば expired にする' do + user.subscription.update!(status: 'active', product_id: 'buzzbase_pro_monthly') + stub_subscriber({}) + subscription = described_class.new(user).call + expect(subscription.status).to eq('expired') + end + end + + context 'entitlementが存在する場合' do + it 'アクティブな加入をactiveとして反映する' do + stub_entitlement + subscription = described_class.new(user).call + + aggregate_failures do + expect(subscription.status).to eq('active') + expect(subscription.plan_type).to eq('monthly') + expect(subscription.platform).to eq('ios') + expect(subscription.product_id).to eq('buzzbase_pro_monthly') + expect(subscription.last_synced_at).to be_present + end + end + + it 'period_type が TRIAL なら trial として反映する' do + stub_entitlement(subscription_overrides: { 'period_type' => 'TRIAL' }) + subscription = described_class.new(user).call + + aggregate_failures do + expect(subscription.status).to eq('trial') + expect(subscription.has_used_trial).to be true + end + end + + it '期限切れ(グレース期間もなし)なら expired にする' do + stub_entitlement(entitlement_overrides: { 'expires_date' => 1.day.ago.iso8601 }) + subscription = described_class.new(user).call + expect(subscription.status).to eq('expired') + end + + it '期限切れでもグレース期間内ならactiveのまま扱い、pro_active?も維持する' do + stub_entitlement( + entitlement_overrides: { + 'expires_date' => 1.day.ago.iso8601, + 'grace_period_expires_date' => 5.days.from_now.iso8601 + } + ) + subscription = described_class.new(user).call + + aggregate_failures do + expect(subscription.status).to eq('active') + # expires_atにグレース期限ではなく本来のexpires_dateだけを保存すると、 + # pro_active?/in_grace_period?が期限切れ判定してしまい、statusと矛盾する。 + expect(subscription.pro_active?).to be true + expect(subscription.expires_at).to be > Time.current + end + end + + it 'billing_issues_detected_atがあればbilling_issueにする' do + stub_entitlement(subscription_overrides: { 'billing_issues_detected_at' => 1.day.ago.iso8601 }) + subscription = described_class.new(user).call + + aggregate_failures do + expect(subscription.status).to eq('billing_issue') + expect(subscription.billing_issue_at).to be_present + end + end + + it 'unsubscribe_detected_atがあればcancelledにする' do + stub_entitlement(subscription_overrides: { 'store' => 'stripe', 'unsubscribe_detected_at' => 1.day.ago.iso8601 }) + subscription = described_class.new(user).call + + aggregate_failures do + expect(subscription.status).to eq('cancelled') + expect(subscription.platform).to eq('web') + expect(subscription.cancelled_at).to be_present + end + end + + it 'PlanCatalogに未登録のproduct_idの場合は更新をスキップしSentryへ警告する' do + stub_entitlement(entitlement_overrides: { 'product_identifier' => 'unknown_product' }) + allow(Sentry).to receive(:capture_message) + + subscription = described_class.new(user).call + + expect(subscription.status).to eq('free') + expect(subscription.plan_type).to be_nil + expect(Sentry).to have_received(:capture_message).with(/unknown product_id/, level: :warning) + end + + it 'PlanCatalogに未登録のstoreの場合は更新をスキップしSentryへ警告する' do + stub_entitlement(subscription_overrides: { 'store' => 'unknown_store' }) + allow(Sentry).to receive(:capture_message) + + subscription = described_class.new(user).call + + expect(subscription.status).to eq('free') + expect(subscription.platform).to be_nil + expect(Sentry).to have_received(:capture_message).with(/unknown product_id/, level: :warning) + end + end + end +end