require 'cgi' require 'rails_helper' require 'securerandom' RSpec.describe 'Wiki API', type: :request do def auth_headers(user) { 'X-Transfer-Code' => user.inheritance_code } end let!(:user) { create_member_user! } let!(:tn) { TagName.create!(name: 'spec_wiki_title') } let!(:page) do Wiki::Commit.create_content!( tag_name: tn, body: 'init', created_by_user: user, message: 'init') end describe 'GET /wiki' do it 'returns wiki pages with title' do get '/wiki' expect(response).to have_http_status(:ok) expect(json).to be_an(Array) expect(json).not_to be_empty expect(json[0]).to have_key('title') expect(json.map { |p| p['title'] }).to include('spec_wiki_title') end end describe 'GET /wiki/:id' do subject(:request) do get "/wiki/#{ page_id }" end let(:page_id) { page.id } context 'when wiki page exists' do it 'returns wiki page with title' do request expect(response).to have_http_status(:ok) expect(json).to include( 'id' => page.id, 'title' => 'spec_wiki_title') end end context 'when wiki page does not exist' do let(:page_id) { 9_999_999 } it 'returns 404' do request expect(response).to have_http_status(:not_found) end end end describe 'POST /wiki' do let(:endpoint) { '/wiki' } let(:member) { create(:user, role: 'member') } let(:guest) { create(:user, role: 'guest') } def auth_headers(user) { 'X-Transfer-Code' => user.inheritance_code } end context 'when not logged in' do it 'returns 401' do post endpoint, params: { title: 'Test', body: 'Hello' } expect(response).to have_http_status(:unauthorized) end end context 'when logged in but not member' do it 'returns 403' do post endpoint, params: { title: 'Test', body: 'Hello' }, headers: auth_headers(guest) expect(response).to have_http_status(:forbidden) end end context 'when params invalid' do it 'returns 422 when title blank' do post endpoint, params: { title: '', body: 'Hello' }, headers: auth_headers(member) expect(response).to have_http_status(:unprocessable_entity) end it 'returns 422 when body blank' do post endpoint, params: { title: 'Test', body: '' }, headers: auth_headers(member) expect(response).to have_http_status(:unprocessable_entity) end end context 'when success' do it 'creates wiki_page and first content revision' do expect do post endpoint, params: { title: 'TestPage', body: "a\nb\nc", message: 'init' }, headers: auth_headers(member) end .to change(WikiPage, :count).by(1) .and change(WikiRevision, :count).by(1) .and change(WikiVersion, :count).by(1) expect(response).to have_http_status(:created) page_id = json.fetch('id') expect(json.fetch('title')).to eq('TestPage') created_page = WikiPage.find(page_id) version = created_page.wiki_versions.order(:version_no).last expect(version).to have_attributes( version_no: 1, event_type: 'create', title: 'TestPage', body: "a\nb\nc", created_by_user_id: member.id ) rev = created_page.current_revision expect(rev).to be_present expect(rev).to be_content expect(rev.message).to eq('init') expect(created_page.body).to eq("a\nb\nc") expect(rev.lines_count).to eq(3) expect(rev.wiki_revision_lines.order(:position).pluck(:position)).to eq([0, 1, 2]) expect(rev.wiki_lines.pluck(:body)).to match_array(['a', 'b', 'c']) end it 'reuses existing WikiLine rows by sha256' do # 先に同じ行を作っておく WikiLine.create!(sha256: Digest::SHA256.hexdigest('a'), body: 'a', created_at: Time.current, updated_at: Time.current) post endpoint, params: { title: 'Reuse', body: "a\na" }, headers: auth_headers(member) page = WikiPage.find(JSON.parse(response.body).fetch('id')) rev = page.current_revision expect(rev.lines_count).to eq(2) # "a" の WikiLine が増殖しない(1行のはず) expect(WikiLine.where(body: 'a').count).to eq(1) end it 'deduplicates duplicated new lines before upsert' do duplicated = 'duplicated_line_for_wiki_line_upsert_spec' post endpoint, params: { title: 'DuplicateNewLine', body: "#{ duplicated }\n#{ duplicated }" }, headers: auth_headers(member) expect(response).to have_http_status(:created) page = WikiPage.find(json.fetch('id')) rev = page.current_revision expect(rev.lines_count).to eq(2) expect(WikiLine.where(body: duplicated).count).to eq(1) expect(rev.wiki_revision_lines.count).to eq(2) expect(rev.wiki_revision_lines.pluck(:wiki_line_id).uniq.size).to eq(1) end it 'normalises CRLF and strips trailing newlines' do post endpoint, params: { title: 'NormalisedBody', body: "a\r\nb\r\n\r\n", message: 'normalise' }, headers: auth_headers(member) expect(response).to have_http_status(:created) page = WikiPage.find(json.fetch('id')) rev = page.current_revision version = page.wiki_versions.order(:version_no).last expect(page.body).to eq("a\nb") expect(version.body).to eq("a\nb") expect(rev.lines_count).to eq(2) expect(rev.wiki_lines.order('wiki_revision_lines.position').map(&:body)).to eq(['a', 'b']) end end end describe 'PUT /wiki/:id' do let(:member) { create(:user, role: 'member', inheritance_code: SecureRandom.hex(16)) } let(:guest) { create(:user, role: 'guest', inheritance_code: SecureRandom.hex(16)) } def auth_headers(user) { 'X-Transfer-Code' => user.inheritance_code } end let!(:test_tag_name) { TagName.create!(name: 'TestPage') } let!(:page) do Wiki::Commit.create_content!( tag_name: test_tag_name, body: "a\nb", created_by_user: member, message: 'init') end context 'when not logged in' do it 'returns 401' do put "/wiki/#{page.id}", params: { title: 'TestPage', body: 'x' } expect(response).to have_http_status(:unauthorized) end end context 'when logged in but not member' do it 'returns 403' do put "/wiki/#{page.id}", params: { title: 'TestPage', body: 'x' }, headers: auth_headers(guest) expect(response).to have_http_status(:forbidden) end end context 'when params invalid' do it 'returns 422 when body blank' do put "/wiki/#{page.id}", params: { title: 'TestPage', body: '' }, headers: auth_headers(member) expect(response).to have_http_status(:unprocessable_entity) end end context 'when success' do it 'creates new revision and returns 200' do current_id = page.wiki_revisions.maximum(:id) expect do put "/wiki/#{page.id}", params: { title: 'TestPage', body: "x\ny", message: 'edit', base_revision_id: current_id }, headers: auth_headers(member) end .to change(WikiRevision, :count).by(1) .and change(WikiVersion, :count).by(1) version = page.wiki_versions.order(:version_no).last expect(version).to have_attributes( event_type: 'update', title: 'TestPage', body: "x\ny", created_by_user_id: member.id ) expect(response).to have_http_status(:ok) page.reload rev = page.current_revision expect(rev).to be_content expect(rev.message).to eq('edit') expect(page.body).to eq("x\ny") expect(rev.base_revision_id).to eq(current_id) end it 'wiki body だけを変更しても tag version は作成しない' do linked_tag_name = TagName.create!(name: 'wiki_body_only_tag') linked_tag = Tag.create!(tag_name: linked_tag_name, category: :general) TagVersionRecorder.record!( tag: linked_tag, event_type: :create, created_by_user: member) linked_page = Wiki::Commit.create_content!( tag_name: linked_tag_name, body: 'before', created_by_user: member, message: 'init') current_id = linked_page.current_revision.id before_count = linked_tag.reload.tag_versions.count expect { put "/wiki/#{ linked_page.id }", params: { title: 'wiki_body_only_tag', body: 'after', message: 'edit', base_revision_id: current_id, }, headers: auth_headers(member) } .to change(WikiRevision, :count).by(1) .and change(WikiVersion, :count).by(1) expect(response).to have_http_status(:ok) expect(linked_tag.reload.tag_versions.count).to eq(before_count) end end # TODO: コンフリクト未実装のため,実装したらコメント外す. # context 'when conflict' do # it 'returns 409 when base_revision_id mismatches' do # # 先に別ユーザ(同じ member でもOK)が 1 回更新して先頭を進める # Wiki::Commit.content!(page: page, body: "zzz", created_user: member, message: 'other edit') # page.reload # stale_id = page.wiki_revisions.order(:id).first.id # わざと古い id # put "/wiki/#{page.id}", # params: { title: 'TestPage', body: 'x', base_revision_id: stale_id }, # headers: auth_headers(member) # expect(response).to have_http_status(:conflict) # json = JSON.parse(response.body) # expect(json['error']).to eq('conflict') # end # end context 'when page not found' do it 'returns 404' do put "/wiki/99999999", params: { title: 'X', body: 'x' }, headers: auth_headers(member) expect(response).to have_http_status(:not_found) end end end describe 'GET /wiki/title/:title' do it 'returns wiki page by title' do get "/wiki/title/#{CGI.escape('spec_wiki_title')}" expect(response).to have_http_status(:ok) expect(json).to have_key('id') expect(json).to have_key('title') expect(json['id']).to eq(page.id) expect(json['title']).to eq('spec_wiki_title') end it 'returns 404 when not found' do get "/wiki/title/#{ CGI.escape('nope') }" expect(response).to have_http_status(:not_found) end end describe 'GET /wiki/search' do before do Wiki::Commit.create_content!( tag_name: TagName.create!(name: 'spec_wiki_title_2'), body: 'search body 2', created_by_user: user, message: 'init') Wiki::Commit.create_content!( tag_name: TagName.create!(name: 'unrelated_title'), body: 'unrelated body', created_by_user: user, message: 'init') 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') expect(titles).to include('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') # 異常データ: revision 無し WikiPage を直接作る p2 = WikiPage.create!( tag_name: tn2, body: 'init', 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 end describe 'Wiki::Commit.redirect!' do it 'raises because redirect revisions are deprecated' do target_tag_name = TagName.create!(name: 'redirect_deprecated_target') target = Wiki::Commit.create_content!( tag_name: target_tag_name, body: 'target', created_by_user: user, message: 'init') expect { Wiki::Commit.redirect!( page: page, redirect_page: target, created_user: user, message: 'redirect', base_revision_id: page.current_revision.id ) }.to raise_error(RuntimeError, '廃止しました.') end end it 'wiki title を変更すると対応する tag の version を作成する' do linked_tag_name = TagName.create!(name: 'wiki_linked_tag_for_version') linked_tag = Tag.create!(tag_name: linked_tag_name, category: :general) linked_page = Wiki::Commit.create_content!( tag_name: linked_tag_name, body: 'before', created_by_user: user, message: 'init') current_id = linked_page.current_revision.id expect { put "/wiki/#{ linked_page.id }", params: { title: 'wiki_linked_tag_for_version_renamed', body: 'after', message: 'edit', base_revision_id: current_id, }, headers: auth_headers(member) } .to change(WikiRevision, :count).by(1) .and change(WikiVersion, :count).by(1) .and change { linked_tag.reload.tag_versions.count }.by(2) expect(response).to have_http_status(:ok) linked_tag.reload expect(linked_tag.name).to eq('wiki_linked_tag_for_version_renamed') versions = linked_tag.tag_versions.order(:version_no) expect(versions.first.event_type).to eq('create') expect(versions.first.name).to eq('wiki_linked_tag_for_version') expect(versions.second.event_type).to eq('update') expect(versions.second.name).to eq('wiki_linked_tag_for_version_renamed') end it 'wiki body だけを変更しても tag version は作成しない' do linked_tag_name = TagName.create!(name: 'wiki_body_only_tag') linked_tag = Tag.create!(tag_name: linked_tag_name, category: :general) linked_page = Wiki::Commit.create_content!( tag_name: linked_tag_name, body: 'before', created_by_user: user, message: 'init') current_id = linked_page.current_revision.id expect { put "/wiki/#{ linked_page.id }", params: { title: 'wiki_body_only_tag', body: 'after', message: 'edit', base_revision_id: current_id, }, headers: auth_headers(member) } .to change(WikiRevision, :count).by(1) .and change(WikiVersion, :count).by(1) expect(linked_tag.reload.tag_versions.count).to eq(0) end it 'wiki title を変更すると対応する tag の version を作成する' do linked_tag_name = TagName.create!(name: 'wiki_linked_tag_for_version') linked_tag = Tag.create!(tag_name: linked_tag_name, category: :general) linked_page = Wiki::Commit.create_content!( tag_name: linked_tag_name, body: 'before', created_by_user: user, message: 'init') current_id = linked_page.current_revision.id expect { put "/wiki/#{ linked_page.id }", params: { title: 'wiki_linked_tag_for_version_renamed', body: 'after', message: 'rename', base_revision_id: current_id, }, headers: auth_headers(user) } .to change(WikiRevision, :count).by(1) .and change(WikiVersion, :count).by(1) .and change { linked_tag.reload.tag_versions.count }.by(2) expect(response).to have_http_status(:ok) linked_tag.reload expect(linked_tag.name).to eq('wiki_linked_tag_for_version_renamed') versions = linked_tag.tag_versions.order(:version_no) expect(versions.first.event_type).to eq('create') expect(versions.first.name).to eq('wiki_linked_tag_for_version') expect(versions.second.event_type).to eq('update') expect(versions.second.name).to eq('wiki_linked_tag_for_version_renamed') end end