diff --git a/backend/spec/requests/posts_spec.rb b/backend/spec/requests/posts_spec.rb index a65a935..7480812 100644 --- a/backend/spec/requests/posts_spec.rb +++ b/backend/spec/requests/posts_spec.rb @@ -1,19 +1,33 @@ require 'rails_helper' +require 'set' RSpec.describe 'Posts API', type: :request do - describe 'GET /posts' do - it 'returns tags with name in JSON' do - tn = TagName.create!(name: 'spec_tag') - tag = Tag.create!(tag_name: tn, category: 'general') + # create / update で thumbnail.attach は走るが、 + # resized_thumbnail! が MiniMagick 依存でコケやすいので request spec ではスタブしとくのが無難。 + before do + allow_any_instance_of(Post).to receive(:resized_thumbnail!).and_return(true) + end + + def dummy_upload + # 中身は何でもいい(加工処理はスタブしてる) + Rack::Test::UploadedFile.new(StringIO.new('dummy'), 'image/jpeg', original_filename: 'dummy.jpg') + end + + let!(:tag_name) { TagName.create!(name: 'spec_tag') } + let!(:tag) { Tag.create!(tag_name: tag_name, category: 'general') } - post = Post.create!(title: 'spec post', url: 'https://example.com/spec') - PostTag.create!(post: post, tag: tag) + let!(:post_record) do + Post.create!(title: 'spec post', url: 'https://example.com/spec').tap do |p| + PostTag.create!(post: p, tag: tag) + end + end + describe 'GET /posts' do + it 'returns posts with tag name in JSON' do get '/posts' expect(response).to have_http_status(:ok) - expect(json).to have_key('posts') expect(json['posts']).to be_an(Array) expect(json['posts']).not_to be_empty @@ -22,11 +36,200 @@ RSpec.describe 'Posts API', type: :request do expect(tags).to be_an(Array) expect(tags).not_to be_empty + # Tag は name カラムを持たないので、API 側が methods: [:name] 等で出す必要がある expect(tags[0]).to have_key('name') - expect(tags[0]['name']).to eq('spec_tag') - expect(tags.map { |t| t['name'] }).to include('spec_tag') expect(tags[0]).to include('category') end end + + describe 'GET /posts/:id' do + subject(:request) { get "/posts/#{post_id}" } + + context 'when post exists' do + let(:post_id) { post_record.id } + + it 'returns post with tag tree + related + viewed' do + request + expect(response).to have_http_status(:ok) + + expect(json).to include('id' => post_record.id) + expect(json).to have_key('tags') + expect(json['tags']).to be_an(Array) + + # show は build_tag_tree_for を使うので、tags はツリー形式(children 付き) + node = json['tags'][0] + expect(node).to include('id', 'name', 'category', 'post_count', 'children') + expect(node['name']).to eq('spec_tag') + + expect(json).to have_key('related') + expect(json['related']).to be_an(Array) + + expect(json).to have_key('viewed') + expect([true, false]).to include(json['viewed']) + end + end + + context 'when post does not exist' do + let(:post_id) { 999_999_999 } + + it 'returns 404' do + request + expect(response).to have_http_status(:not_found) + end + end + end + + describe 'POST /posts' do + let(:member) { create(:user, :member) } + + it '401 when not logged in' do + sign_out + post '/posts', params: { title: 't', url: 'https://example.com/x', tags: 'a', thumbnail: dummy_upload } + expect(response).to have_http_status(:unauthorized) + end + + it '403 when not member' do + sign_in_as(create(:user, role: 'guest')) + post '/posts', params: { title: 't', url: 'https://example.com/x', tags: 'a', thumbnail: dummy_upload } + expect(response).to have_http_status(:forbidden) + end + + it '201 and creates post + tags when member' do + sign_in_as(member) + + post '/posts', params: { + title: 'new post', + url: 'https://example.com/new', + tags: 'spec_tag', # 既存タグ名を投げる + thumbnail: dummy_upload + } + + expect(response).to have_http_status(:created) + expect(json).to include('id', 'title', 'url') + + # tags が name を含むこと(API 側の serialization が正しいこと) + expect(json).to have_key('tags') + expect(json['tags']).to be_an(Array) + expect(json['tags'][0]).to have_key('name') + end + end + + describe 'PUT /posts/:id' do + let(:member) { create(:user, :member) } + + it '401 when not logged in' do + sign_out + put "/posts/#{post_record.id}", params: { title: 'updated', tags: 'spec_tag' } + expect(response).to have_http_status(:unauthorized) + end + + it '403 when not member' do + sign_in_as(create(:user, role: 'guest')) + put "/posts/#{post_record.id}", params: { title: 'updated', tags: 'spec_tag' } + expect(response).to have_http_status(:forbidden) + end + + it '200 and updates title + resync tags when member' do + sign_in_as(member) + + # 追加で別タグも作って、更新時に入れ替わることを見る + tn2 = TagName.create!(name: 'spec_tag_2') + Tag.create!(tag_name: tn2, category: 'general') + + put "/posts/#{post_record.id}", params: { + title: 'updated title', + tags: 'spec_tag_2' + } + + expect(response).to have_http_status(:ok) + expect(json).to have_key('tags') + expect(json['tags']).to be_an(Array) + + # show と同様、update 後レスポンスもツリー形式 + names = json['tags'].map { |n| n['name'] } + expect(names).to include('spec_tag_2') + end + end + + describe 'GET /posts/random' do + it '404 when no posts' do + PostTag.delete_all + Post.delete_all + get '/posts/random' + expect(response).to have_http_status(:not_found) + end + + it '200 and returns viewed boolean' do + get '/posts/random' + expect(response).to have_http_status(:ok) + expect(json).to have_key('viewed') + expect([true, false]).to include(json['viewed']) + end + end + + describe 'GET /posts/changes' do + let(:member) { create(:user, :member) } + + it 'returns add/remove events (history) for a post' do + # add + tn2 = TagName.create!(name: 'spec_tag2') + tag2 = Tag.create!(tag_name: tn2, category: 'general') + pt = PostTag.create!(post: post_record, tag: tag2, created_user: member) + + # remove (discard) + pt.discard_by!(member) + + get '/posts/changes', params: { id: post_record.id } + + expect(response).to have_http_status(:ok) + expect(json).to include('changes', 'count') + expect(json['changes']).to be_an(Array) + expect(json['count']).to be >= 2 + + types = json['changes'].map { |e| e['change_type'] }.uniq + expect(types).to include('add') + expect(types).to include('remove') + end + end + + describe 'POST /posts/:id/viewed' do + let(:user) { create(:user) } + + it '401 when not logged in' do + sign_out + post "/posts/#{ post_record.id }/viewed" + expect(response).to have_http_status(:unauthorized) + end + + it '204 and marks viewed when logged in' do + sign_in_as(user) + post "/posts/#{ post_record.id }/viewed" + expect(response).to have_http_status(:no_content) + + expect(user.reload.viewed?(post_record)).to be(true) + end + end + + describe 'DELETE /posts/:id/unviewed' do + let(:user) { create(:user) } + + it '401 when not logged in' do + sign_out + delete "/posts/#{ post_record.id }/viewed" + expect(response).to have_http_status(:unauthorized) + end + + it '204 and unmarks viewed when logged in' do + sign_in_as(user) + + # 先に viewed 付けてから外す + user.viewed_posts << post_record + + delete "/posts/#{ post_record.id }/viewed" + expect(response).to have_http_status(:no_content) + + expect(user.reload.viewed?(post_record)).to be(false) + end + end end diff --git a/backend/spec/requests/users_spec.rb b/backend/spec/requests/users_spec.rb index 66ed82c..89003a4 100644 --- a/backend/spec/requests/users_spec.rb +++ b/backend/spec/requests/users_spec.rb @@ -12,10 +12,99 @@ RSpec.describe "Users", type: :request do end describe "POST /users/code/renew" do - it "should be 401 when not logged in (recommended behavior)" do + it "returns 401 when not logged in" do sign_out post "/users/code/renew" expect(response).to have_http_status(:unauthorized) end end + + describe "PUT /users/:id" do + let(:user) { create(:user, name: "old-name", role: "guest") } + + it "returns 401 when current_user id mismatch" do + sign_in_as(create(:user)) + put "/users/#{user.id}", params: { name: "new-name" } + expect(response).to have_http_status(:unauthorized) + end + + it "returns 400 when name is blank" do + sign_in_as(user) + put "/users/#{user.id}", params: { name: " " } + expect(response).to have_http_status(:bad_request) + end + + it "updates name and returns 201 with user slice" do + sign_in_as(user) + put "/users/#{user.id}", params: { name: "new-name" } + + expect(response).to have_http_status(:created) + expect(json["id"]).to eq(user.id) + expect(json["name"]).to eq("new-name") + + user.reload + expect(user.name).to eq("new-name") + end + end + + describe "POST /users/verify" do + it "returns valid:false when code not found" do + post "/users/verify", params: { code: "nope" } + expect(response).to have_http_status(:ok) + expect(json["valid"]).to eq(false) + end + + it "creates IpAddress and UserIp, and returns valid:true with user slice" do + user = create(:user, inheritance_code: SecureRandom.uuid, role: "guest") + + # request.remote_ip を固定 + allow_any_instance_of(ActionDispatch::Request).to receive(:remote_ip).and_return("203.0.113.10") + + expect { + post "/users/verify", params: { code: user.inheritance_code } + }.to change(UserIp, :count).by(1) + + expect(response).to have_http_status(:ok) + expect(json["valid"]).to eq(true) + expect(json["user"]["id"]).to eq(user.id) + expect(json["user"]["inheritance_code"]).to eq(user.inheritance_code) + expect(json["user"]["role"]).to eq("guest") + + # ついでに IpAddress もできてることを確認(ipの保存形式がバイナリでも count で見れる) + expect(IpAddress.count).to be >= 1 + end + + it "is idempotent for same user+ip (does not create duplicate UserIp)" do + user = create(:user, inheritance_code: SecureRandom.uuid, role: "guest") + allow_any_instance_of(ActionDispatch::Request).to receive(:remote_ip).and_return("203.0.113.10") + + post "/users/verify", params: { code: user.inheritance_code } + expect(response).to have_http_status(:ok) + + expect { + post "/users/verify", params: { code: user.inheritance_code } + }.not_to change(UserIp, :count) + + expect(response).to have_http_status(:ok) + expect(json["valid"]).to eq(true) + end + end + + describe "GET /users/me" do + it "returns 404 when code not found" do + get "/users/me", params: { code: "nope" } + expect(response).to have_http_status(:not_found) + end + + it "returns user slice when found" do + user = create(:user, inheritance_code: SecureRandom.uuid, name: "me", role: "guest") + get "/users/me", params: { code: user.inheritance_code } + + expect(response).to have_http_status(:ok) + expect(json["id"]).to eq(user.id) + expect(json["name"]).to eq("me") + expect(json["inheritance_code"]).to eq(user.inheritance_code) + expect(json["role"]).to eq("guest") + end + end end diff --git a/backend/spec/requests/wiki_spec.rb b/backend/spec/requests/wiki_spec.rb index cf3dc07..6b5fe1f 100644 --- a/backend/spec/requests/wiki_spec.rb +++ b/backend/spec/requests/wiki_spec.rb @@ -256,4 +256,169 @@ RSpec.describe 'Wiki API', type: :request do expect(response).to have_http_status(:not_found) end end + + describe 'GET /wiki/search' do + before do + # 追加で検索ヒット用 + TagName.create!(name: 'spec_wiki_title_2') + WikiPage.create!(tag_name: TagName.find_by!(name: 'spec_wiki_title_2'), + created_user: user, updated_user: user) + + TagName.create!(name: 'unrelated_title') + WikiPage.create!(tag_name: TagName.find_by!(name: 'unrelated_title'), + created_user: user, updated_user: user) + end + + it 'returns up to 20 pages filtered by title like' do + get "/wiki/search?title=#{CGI.escape('spec_wiki')}" + + expect(response).to have_http_status(:ok) + expect(json).to be_an(Array) + + titles = json.map { |p| p['title'] } + expect(titles).to include('spec_wiki_title', 'spec_wiki_title_2') + expect(titles).not_to include('unrelated_title') + end + + it 'returns all when title param is blank' do + get "/wiki/search?title=#{CGI.escape('')}" + + expect(response).to have_http_status(:ok) + expect(json).to be_an(Array) + expect(json.map { |p| p['title'] }).to include('spec_wiki_title') + end + end + + describe 'GET /wiki/changes' do + let!(:rev1) do + Wiki::Commit.content!(page: page, body: "a\nb", created_user: user, message: 'r1') + page.current_revision + end + + let!(:rev2) do + Wiki::Commit.content!(page: page, body: "a\nc", created_user: user, message: 'r2') + page.current_revision + end + + it 'returns latest revisions (optionally filtered by page id)' do + get "/wiki/changes?id=#{page.id}" + + expect(response).to have_http_status(:ok) + expect(json).to be_an(Array) + expect(json).not_to be_empty + + top = json.first + expect(top).to include( + 'revision_id' => rev2.id, + 'pred' => rev2.base_revision_id, + 'succ' => nil, + 'kind' => 'content', + 'message' => 'r2' + ) + expect(top['wiki_page']).to include('id' => page.id, 'title' => 'spec_wiki_title') + expect(top['user']).to include('id' => user.id, 'name' => user.name) + expect(top).to have_key('timestamp') + + # order desc をざっくり担保 + ids = json.map { |x| x['revision_id'] } + expect(ids).to eq(ids.sort.reverse) + end + + it 'returns empty array when page has no revisions and filtered by id' do + # 別ページを作って revision 無し + tn2 = TagName.create!(name: 'spec_no_rev') + p2 = WikiPage.create!(tag_name: tn2, created_user: user, updated_user: user) + + get "/wiki/changes?id=#{p2.id}" + expect(response).to have_http_status(:ok) + expect(json).to eq([]) + end + end + + describe 'GET /wiki/title/:title/exists' do + it 'returns 204 when exists' do + get "/wiki/title/#{CGI.escape('spec_wiki_title')}/exists" + expect(response).to have_http_status(:no_content) + expect(response.body).to be_empty + end + + it 'returns 404 when not exists' do + get "/wiki/title/#{CGI.escape('nope')}/exists" + expect(response).to have_http_status(:not_found) + end + end + + describe 'GET /wiki/:id/exists' do + it 'returns 204 when exists' do + get "/wiki/#{page.id}/exists" + expect(response).to have_http_status(:no_content) + expect(response.body).to be_empty + end + + it 'returns 404 when not exists' do + get "/wiki/99999999/exists" + expect(response).to have_http_status(:not_found) + end + end + + describe 'GET /wiki/:id/diff' do + let!(:rev_a) do + Wiki::Commit.content!(page: page, body: "a\nb\nc", created_user: user, message: 'A') + page.current_revision + end + + let!(:rev_b) do + Wiki::Commit.content!(page: page, body: "a\nx\nc", created_user: user, message: 'B') + page.current_revision + end + + it 'returns diff json between revisions' do + get "/wiki/#{page.id}/diff?from=#{rev_a.id}&to=#{rev_b.id}" + + expect(response).to have_http_status(:ok) + + expect(json).to include( + 'wiki_page_id' => page.id, + 'title' => 'spec_wiki_title', + 'older_revision_id' => rev_a.id, + 'newer_revision_id' => rev_b.id + ) + expect(json['diff']).to be_an(Array) + # ざっくり「b が消えて x が増えた」が含まれることを確認 + types = json['diff'].map { |x| x['type'] } + expect(types).to include('removed', 'added').or include('removed').and include('added') + end + + it 'uses latest as "to" when to is omitted' do + get "/wiki/#{page.id}/diff?from=#{rev_a.id}" + + expect(response).to have_http_status(:ok) + expect(json['older_revision_id']).to eq(rev_a.id) + expect(json['newer_revision_id']).to eq(page.current_revision.id) + end + + it 'returns 422 when "to" is redirect revision' do + # redirect revision を作る + tn2 = TagName.create!(name: 'redirect_target') + target = WikiPage.create!(tag_name: tn2, created_user: user, updated_user: user) + + Wiki::Commit.redirect!(page: page, redirect_page: target, created_user: user, message: 'redir') + redirect_rev = page.current_revision + expect(redirect_rev).to be_redirect + + get "/wiki/#{page.id}/diff?from=#{rev_a.id}&to=#{redirect_rev.id}" + expect(response).to have_http_status(:unprocessable_entity) + end + + it 'returns 422 when "from" is redirect revision' do + tn2 = TagName.create!(name: 'redirect_target2') + target = WikiPage.create!(tag_name: tn2, created_user: user, updated_user: user) + + Wiki::Commit.redirect!(page: page, redirect_page: target, created_user: user, message: 'redir2') + redirect_rev = page.current_revision + + get "/wiki/#{page.id}/diff?from=#{redirect_rev.id}&to=#{rev_b.id}" + expect(response).to have_http_status(:unprocessable_entity) + end + end end