| @@ -1,19 +1,33 @@ | |||||
| require 'rails_helper' | require 'rails_helper' | ||||
| require 'set' | |||||
| RSpec.describe 'Posts API', type: :request do | 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' | get '/posts' | ||||
| expect(response).to have_http_status(:ok) | expect(response).to have_http_status(:ok) | ||||
| expect(json).to have_key('posts') | expect(json).to have_key('posts') | ||||
| expect(json['posts']).to be_an(Array) | expect(json['posts']).to be_an(Array) | ||||
| expect(json['posts']).not_to be_empty | 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).to be_an(Array) | ||||
| expect(tags).not_to be_empty | expect(tags).not_to be_empty | ||||
| # Tag は name カラムを持たないので、API 側が methods: [:name] 等で出す必要がある | |||||
| expect(tags[0]).to have_key('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.map { |t| t['name'] }).to include('spec_tag') | ||||
| expect(tags[0]).to include('category') | expect(tags[0]).to include('category') | ||||
| end | end | ||||
| 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 | end | ||||
| @@ -12,10 +12,99 @@ RSpec.describe "Users", type: :request do | |||||
| end | end | ||||
| describe "POST /users/code/renew" do | 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 | sign_out | ||||
| post "/users/code/renew" | post "/users/code/renew" | ||||
| expect(response).to have_http_status(:unauthorized) | expect(response).to have_http_status(:unauthorized) | ||||
| end | end | ||||
| 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 | end | ||||
| @@ -256,4 +256,169 @@ RSpec.describe 'Wiki API', type: :request do | |||||
| expect(response).to have_http_status(:not_found) | expect(response).to have_http_status(:not_found) | ||||
| end | end | ||||
| 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 | end | ||||