require 'rails_helper' RSpec.describe 'Gekanator learning API', type: :request do let(:admin) { create(:user, :admin) } let(:member) { create(:user, :member) } let(:other_user) { create(:user, :member) } let!(:guessed_post) do Post.create!( title: 'guessed', url: 'https://example.com/guessed' ) end let!(:correct_post) do Post.create!( title: 'correct', url: 'https://example.com/correct' ) end let!(:other_post) do Post.create!( title: 'other', url: 'https://example.com/other' ) end let!(:game) do GekanatorGame.create!( user: admin, guessed_post: guessed_post, correct_post: correct_post, won: false, question_count: 1, answers: [ { 'question_id' => 'tag:character:喜多郁代', 'question_text' => '喜多ちゃんが関係してる?', 'answer' => 'yes', 'original_answer' => 'yes' } ] ) end def create_post_similarity_question!( text: '喜多ちゃんが泣いてる?', post: correct_post, answer: 'yes', status: 'accepted', source: 'user_suggested', priority_weight: 1.2 ) GekanatorQuestion.create!( text: text, kind: 'post_similarity', source: source, status: status, priority_weight: priority_weight, condition: { type: 'post-similarity', postId: post.id, answer: answer, threshold: 0.65 }, created_by: admin ) end describe 'POST /gekanator/games' do it 'stores a game result for an admin user' do sign_in_as admin post '/gekanator/games', params: { guessed_post_id: guessed_post.id, correct_post_id: correct_post.id, answers: [ { question_id: 'tag:character:喜多郁代', question_text: '喜多ちゃんが関係してる?', answer: 'yes', original_answer: 'yes' } ] } expect(response).to have_http_status(:created) created = GekanatorGame.find(json['id']) expect(created.user).to eq(admin) expect(created.guessed_post).to eq(guessed_post) expect(created.correct_post).to eq(correct_post) expect(created.won).to eq(false) expect(created.question_count).to eq(1) expect(created.answers).to eq([ { 'question_id' => 'tag:character:喜多郁代', 'question_text' => '喜多ちゃんが関係してる?', 'answer' => 'yes', 'original_answer' => 'yes' } ]) end it 'stores a won game when guessed_post_id equals correct_post_id' do sign_in_as admin post '/gekanator/games', params: { guessed_post_id: correct_post.id, correct_post_id: correct_post.id, answers: [] } expect(response).to have_http_status(:created) expect(GekanatorGame.find(json['id']).won).to eq(true) end it 'rejects a game without correct_post_id' do sign_in_as admin expect { post '/gekanator/games', params: { guessed_post_id: guessed_post.id, answers: [] } }.not_to change { GekanatorGame.count } expect(response).to have_http_status(:unprocessable_entity) end it 'stores a game result for a non-admin user' do sign_in_as member post '/gekanator/games', params: { guessed_post_id: guessed_post.id, correct_post_id: correct_post.id, answers: [] } expect(response).to have_http_status(:created) expect(GekanatorGame.find(json['id']).user).to eq(member) end it 'returns unauthorized without a user' do post '/gekanator/games', params: { guessed_post_id: guessed_post.id, correct_post_id: correct_post.id, answers: [] } expect(response).to have_http_status(:unauthorized) end end describe 'POST /gekanator/question_suggestions' do it 'creates a suggestion and promotes yes answer to an accepted post_similarity question' do sign_in_as admin expect { post '/gekanator/question_suggestions', params: { gekanator_game_id: game.id, question_text: '喜多ちゃんが泣いてる?', answer: 'yes' } }.to change { GekanatorQuestionSuggestion.count }.by(1) .and change { GekanatorQuestion.count }.by(1) .and change { GekanatorQuestionExample.count }.by(1) expect(response).to have_http_status(:created) suggestion = GekanatorQuestionSuggestion.last question = GekanatorQuestion.last example = GekanatorQuestionExample.last expect(json).to include( 'id' => suggestion.id, 'count' => 1 ) expect(suggestion).to have_attributes( gekanator_game_id: game.id, user_id: admin.id, question_text: '喜多ちゃんが泣いてる?', answer: 'yes', processed: true ) expect(question).to have_attributes( text: '喜多ちゃんが泣いてる?', kind: 'post_similarity', source: 'user_suggested', status: 'accepted', priority_weight: 1.2, gekanator_question_suggestion_id: suggestion.id, created_by_id: admin.id ) expect(question.condition).to include( 'type' => 'post-similarity', 'postId' => correct_post.id, 'answer' => 'yes', 'threshold' => 0.65 ) expect(example).to have_attributes( gekanator_question_id: question.id, post_id: correct_post.id, user_id: admin.id, gekanator_game_id: game.id, answer: 'yes', source: 'initial_suggestion', weight: 1.0 ) end it 'promotes no, partial, and probably_no answers' do sign_in_as admin ['no', 'partial', 'probably_no'].each do |answer| expect { post '/gekanator/question_suggestions', params: { gekanator_game_id: game.id, question_text: "answer #{answer} question?", answer: answer } }.to change { GekanatorQuestion.count }.by(1) .and change { GekanatorQuestionExample.count }.by(1) expect(response).to have_http_status(:created) expect(GekanatorQuestion.last.condition['answer']).to eq(answer) expect(GekanatorQuestionExample.last.answer).to eq(answer) end end it 'does not promote unknown answers' do sign_in_as admin expect { post '/gekanator/question_suggestions', params: { gekanator_game_id: game.id, question_text: 'よく分からない質問?', answer: 'unknown' } }.to change { GekanatorQuestionSuggestion.count }.by(1) .and change { GekanatorQuestion.count }.by(0) .and change { GekanatorQuestionExample.count }.by(0) expect(response).to have_http_status(:created) expect(GekanatorQuestionSuggestion.last.processed).to eq(false) end it 'limits suggestions to three per game' do sign_in_as admin 3.times do |i| GekanatorQuestionSuggestion.create!( gekanator_game: game, user: admin, question_text: "existing question #{i}", answer: 'unknown' ) end expect { post '/gekanator/question_suggestions', params: { gekanator_game_id: game.id, question_text: 'fourth question?', answer: 'yes' } }.not_to change { GekanatorQuestionSuggestion.count } expect(response).to have_http_status(:unprocessable_entity) end it 'allows a non-admin user to suggest a question for their own game' do member_game = GekanatorGame.create!( user: member, guessed_post: guessed_post, correct_post: correct_post, won: false, question_count: 1, answers: [{ 'question_id' => 'tag:1', 'answer' => 'yes' }] ) sign_in_as member expect { post '/gekanator/question_suggestions', params: { gekanator_game_id: member_game.id, question_text: 'member question?', answer: 'yes' } }.to change { GekanatorQuestionSuggestion.count }.by(1) expect(response).to have_http_status(:created) expect(GekanatorQuestionSuggestion.last).to have_attributes( gekanator_game_id: member_game.id, user_id: member.id ) end it 'returns not found for another user game' do sign_in_as member expect { post '/gekanator/question_suggestions', params: { gekanator_game_id: game.id, question_text: 'member question?', answer: 'yes' } }.not_to change { GekanatorQuestionSuggestion.count } expect(response).to have_http_status(:not_found) end it 'returns unauthorized without a user' do expect { post '/gekanator/question_suggestions', params: { gekanator_game_id: game.id, question_text: 'member question?', answer: 'yes' } }.not_to change { GekanatorQuestionSuggestion.count } expect(response).to have_http_status(:unauthorized) end end describe 'GET /gekanator/games/:id/extra_questions' do it 'returns at most two accepted user_suggested post_similarity questions without duplicates' do sign_in_as admin low = create_post_similarity_question!( text: 'low?', priority_weight: 1.0 ) high = create_post_similarity_question!( text: 'high?', priority_weight: 3.0 ) middle = create_post_similarity_question!( text: 'middle?', priority_weight: 2.0 ) get "/gekanator/games/#{game.id}/extra_questions" expect(response).to have_http_status(:ok) expect(json['questions'].length).to eq(2) expect(json['questions'].map { _1['id'] }.uniq.length).to eq(2) expect(json['questions'].map { _1['id'] }).to all(be_in([low.id, high.id, middle.id])) end it 'can return questions that already have an example for the correct post' do sign_in_as admin existing = create_post_similarity_question!(text: 'already learned?') GekanatorQuestionExample.create!( gekanator_question: existing, post: correct_post, user: admin, gekanator_game: game, answer: 'yes', source: 'post_game_extra' ) get "/gekanator/games/#{game.id}/extra_questions" expect(response).to have_http_status(:ok) expect(json['questions'].map { _1['id'] }).to include(existing.id) end it 'can return questions already asked in the game using snake_case question_id' do sign_in_as admin asked = create_post_similarity_question!(text: 'already asked?') game.update!( answers: [ { 'question_id' => "post-similarity:#{asked.id}", 'answer' => 'yes' } ] ) get "/gekanator/games/#{game.id}/extra_questions" expect(response).to have_http_status(:ok) expect(json['questions'].map { _1['id'] }).to include(asked.id) end it 'can return questions already asked in the game using camelCase questionId' do sign_in_as admin asked = create_post_similarity_question!(text: 'already asked?') game.update!( answers: [ { 'questionId' => "post-similarity:#{asked.id}", 'answer' => 'yes' } ] ) get "/gekanator/games/#{game.id}/extra_questions" expect(response).to have_http_status(:ok) expect(json['questions'].map { _1['id'] }).to include(asked.id) end it 'does not return non-accepted, non-user_suggested, or non-post_similarity questions' do sign_in_as admin accepted = create_post_similarity_question!(text: 'accepted?') create_post_similarity_question!(text: 'disabled?', status: 'disabled') create_post_similarity_question!(text: 'ai?', source: 'ai_generated') GekanatorQuestion.create!( text: 'tag?', kind: 'tag', source: 'user_suggested', status: 'accepted', priority_weight: 1.0, condition: { type: 'tag', key: 'character:喜多郁代' } ) get "/gekanator/games/#{game.id}/extra_questions" expect(response).to have_http_status(:ok) expect(json['questions'].map { _1['id'] }).to contain_exactly(accepted.id) end it 'allows a non-admin user to fetch extra questions for their own game' do member_game = GekanatorGame.create!( user: member, guessed_post: guessed_post, correct_post: correct_post, won: false, question_count: 1, answers: [{ 'question_id' => 'tag:1', 'answer' => 'yes' }] ) accepted = create_post_similarity_question!(text: 'accepted?') sign_in_as member get "/gekanator/games/#{member_game.id}/extra_questions" expect(response).to have_http_status(:ok) expect(json['questions'].map { _1['id'] }).to include(accepted.id) end it 'returns not found for another user game' do sign_in_as member get "/gekanator/games/#{game.id}/extra_questions" expect(response).to have_http_status(:not_found) end it 'returns unauthorized without a user' do get "/gekanator/games/#{game.id}/extra_questions" expect(response).to have_http_status(:unauthorized) end end describe 'POST /gekanator/games/:id/extra_question_answers' do it 'creates examples for extra question answers' do sign_in_as admin question = create_post_similarity_question!(text: 'extra?') expect { post "/gekanator/games/#{game.id}/extra_question_answers", params: { answers: [ { question_id: question.id.to_s, answer: 'partial' } ] } }.to change { GekanatorQuestionExample.count }.by(1) expect(response).to have_http_status(:created) expect(json).to include('count' => 1) example = GekanatorQuestionExample.last expect(example).to have_attributes( gekanator_question_id: question.id, post_id: correct_post.id, user_id: admin.id, gekanator_game_id: game.id, answer: 'partial', source: 'post_game_extra', weight: 1.0 ) end it 'updates an existing example for the same question, post, and user' do sign_in_as admin question = create_post_similarity_question!(text: 'extra?') existing = GekanatorQuestionExample.create!( gekanator_question: question, post: correct_post, user: admin, answer: 'no', source: 'post_game_extra', weight: 1.0 ) expect { post "/gekanator/games/#{game.id}/extra_question_answers", params: { answers: [ { question_id: question.id, answer: 'yes' } ] } }.not_to change { GekanatorQuestionExample.count } expect(response).to have_http_status(:created) expect(existing.reload).to have_attributes( answer: 'yes', source: 'post_game_extra', gekanator_game_id: game.id ) end it 'rejects missing questions' do sign_in_as admin expect { post "/gekanator/games/#{game.id}/extra_question_answers", params: { answers: [ { question_id: 999_999_999, answer: 'yes' } ] } }.not_to change { GekanatorQuestionExample.count } expect(response).to have_http_status(:unprocessable_entity) end it 'rejects non-accepted questions' do sign_in_as admin question = create_post_similarity_question!( text: 'disabled?', status: 'disabled' ) post "/gekanator/games/#{game.id}/extra_question_answers", params: { answers: [ { question_id: question.id, answer: 'yes' } ] } expect(response).to have_http_status(:unprocessable_entity) end it 'rejects non-post_similarity questions' do sign_in_as admin question = GekanatorQuestion.create!( text: 'tag?', kind: 'tag', source: 'user_suggested', status: 'accepted', priority_weight: 1.0, condition: { type: 'tag', key: 'character:喜多郁代' } ) post "/gekanator/games/#{game.id}/extra_question_answers", params: { answers: [ { question_id: question.id, answer: 'yes' } ] } expect(response).to have_http_status(:unprocessable_entity) end it 'allows a non-admin user to answer extra questions for their own game' do member_game = GekanatorGame.create!( user: member, guessed_post: guessed_post, correct_post: correct_post, won: false, question_count: 1, answers: [{ 'question_id' => 'tag:1', 'answer' => 'yes' }] ) question = create_post_similarity_question!(text: 'extra?') sign_in_as member expect { post "/gekanator/games/#{member_game.id}/extra_question_answers", params: { answers: [ { question_id: question.id, answer: 'yes' } ] } }.to change { GekanatorQuestionExample.count }.by(1) expect(response).to have_http_status(:created) expect(GekanatorQuestionExample.last).to have_attributes( user_id: member.id, gekanator_game_id: member_game.id ) end it 'returns not found for another user game' do question = create_post_similarity_question!(text: 'extra?') sign_in_as member expect { post "/gekanator/games/#{game.id}/extra_question_answers", params: { answers: [ { question_id: question.id, answer: 'yes' } ] } }.not_to change { GekanatorQuestionExample.count } expect(response).to have_http_status(:not_found) end it 'returns unauthorized without a user' do question = create_post_similarity_question!(text: 'extra?') post "/gekanator/games/#{game.id}/extra_question_answers", params: { answers: [ { question_id: question.id, answer: 'yes' } ] } expect(response).to have_http_status(:unauthorized) end end describe 'GET /gekanator/questions' do it 'returns accepted questions only and includes example_answers for post_similarity questions' do sign_in_as admin accepted = create_post_similarity_question!(text: 'accepted?') create_post_similarity_question!( text: 'disabled?', status: 'disabled' ) GekanatorQuestionExample.create!( gekanator_question: accepted, post: correct_post, user: admin, answer: 'yes', source: 'initial_suggestion', weight: 1.0 ) get '/gekanator/questions' expect(response).to have_http_status(:ok) expect(json['questions'].length).to eq(1) question_json = json['questions'].first expect(question_json).to include( 'id' => "post-similarity:#{accepted.id}", 'text' => 'accepted?', 'kind' => 'post_similarity', 'source' => 'user_suggested', 'priority_weight' => 1.2 ) expect(question_json['condition']).to include( 'type' => 'post-similarity', 'postId' => correct_post.id, 'answer' => 'yes', 'threshold' => 0.65 ) expect(question_json['example_answers']).to include( correct_post.id.to_s => 'yes' ) end it 'aggregates example_answers by weight' do sign_in_as admin question = create_post_similarity_question!(text: 'weighted?') GekanatorQuestionExample.create!( gekanator_question: question, post: other_post, user: admin, answer: 'yes', source: 'post_game_extra', weight: 1.0 ) GekanatorQuestionExample.create!( gekanator_question: question, post: other_post, user: other_user, answer: 'no', source: 'post_game_extra', weight: 2.0 ) get '/gekanator/questions' expect(response).to have_http_status(:ok) question_json = json['questions'].find { _1['id'] == "post-similarity:#{question.id}" } expect(question_json['example_answers']).to include( other_post.id.to_s => 'no' ) end it 'normalizes legacy title length questions' do sign_in_as admin GekanatorQuestion.create!( text: '題名が長めの投稿?', kind: 'title', source: 'admin_curated', status: 'accepted', priority_weight: 1.0, condition: { type: 'title-length-greater-than', length: 20 }, created_by: admin ) get '/gekanator/questions' expect(response).to have_http_status(:ok) question_json = json['questions'].find { _1['id'] == 'title:length-at-least:21' } expect(question_json).to include( 'text' => 'タイトルは 21 文字以上?', 'kind' => 'title' ) expect(question_json['condition']).to include( 'type' => 'title-length-at-least', 'length' => 21 ) end it 'returns title-contains questions without authentication' do GekanatorQuestion.create!( text: '題名に「結束バンド」が含まれる?', kind: 'title', source: 'ai_generated', status: 'accepted', priority_weight: 0.95, condition: { type: 'title-contains', text: '結束バンド' }, created_by: admin ) get '/gekanator/questions' expect(response).to have_http_status(:ok) question_json = json['questions'].find { _1['id'] == 'title:contains:結束バンド' } expect(question_json).to include( 'text' => '題名に「結束バンド」が含まれる?', 'kind' => 'title' ) expect(question_json['condition']).to include( 'type' => 'title-contains', 'text' => '結束バンド' ) end end end