e03cc01109
#171 #171 #171 #171 #171 #171 #171 Co-authored-by: miteruzo <miteruzo@naver.com> Reviewed-on: #345
1774 lines
57 KiB
Ruby
1774 lines
57 KiB
Ruby
require 'rails_helper'
|
|
require 'set'
|
|
|
|
include ActiveSupport::Testing::TimeHelpers
|
|
|
|
RSpec.describe 'Posts API', type: :request do
|
|
# 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 create_nico_tag!(name)
|
|
Tag.find_or_create_by_tag_name!(name, category: :nico)
|
|
end
|
|
|
|
def dummy_upload
|
|
# 中身は何でもいい(加工処理はスタブしてる)
|
|
Rack::Test::UploadedFile.new(StringIO.new('dummy'), 'image/jpeg', original_filename: 'dummy.jpg')
|
|
end
|
|
|
|
def post_write_params params = { }
|
|
{ parent_post_ids: '' }.merge(params)
|
|
end
|
|
|
|
def create_parent_post! title:, url:
|
|
Post.create!(title:, url:)
|
|
end
|
|
|
|
def create_post_version_for!(post)
|
|
version =
|
|
PostVersion.create!(
|
|
post:,
|
|
version_no: 1,
|
|
event_type: 'create',
|
|
title: post.title,
|
|
url: post.url,
|
|
thumbnail_base: post.thumbnail_base,
|
|
tags: post.snapshot_tag_names.join(' '),
|
|
parent_post_ids: post.snapshot_parent_post_ids.join(' '),
|
|
original_created_from: post.original_created_from,
|
|
original_created_before: post.original_created_before,
|
|
created_at: post.created_at,
|
|
created_by_user: post.uploaded_user)
|
|
|
|
post.update_columns(version_no: version.version_no) if post.has_attribute?(:version_no)
|
|
post.version_no = version.version_no if post.respond_to?(:version_no=)
|
|
|
|
version
|
|
end
|
|
|
|
def post_update_params(post, params = { })
|
|
base_version =
|
|
post.post_versions.order(version_no: :desc).first ||
|
|
create_post_version_for!(post.reload)
|
|
|
|
post_write_params({ base_version_no: base_version.version_no }.merge(params))
|
|
end
|
|
|
|
let!(:tag_name) { TagName.create!(name: 'spec_tag') }
|
|
let!(:tag) { Tag.create!(tag_name: tag_name, category: :general) }
|
|
|
|
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
|
|
let!(:user) { create_member_user! }
|
|
|
|
let!(:tag_name) { TagName.create!(name: "spec_tag") }
|
|
let!(:tag) { Tag.create!(tag_name:, category: :general) }
|
|
let!(:tag_name2) { TagName.create!(name: 'unko') }
|
|
let!(:tag2) { Tag.create!(tag_name: tag_name2, category: :deerjikist) }
|
|
let!(:alias_tag_name) { TagName.create!(name: 'manko', canonical: tag_name) }
|
|
|
|
let!(:hit_post) do
|
|
Post.create!(uploaded_user: user, title: "hello spec world",
|
|
url: 'https://example.com/spec2').tap do |p|
|
|
PostTag.create!(post: p, tag:)
|
|
end
|
|
end
|
|
|
|
let!(:miss_post) do
|
|
Post.create!(uploaded_user: user, title: "unrelated title",
|
|
url: 'https://example.com/spec3').tap do |p|
|
|
PostTag.create!(post: p, tag: tag2)
|
|
end
|
|
end
|
|
|
|
it "returns posts with tag name in JSON" do
|
|
get "/posts"
|
|
|
|
expect(response).to have_http_status(:ok)
|
|
posts = json.fetch("posts")
|
|
|
|
# 全postの全tagが name を含むこと
|
|
expect(posts).not_to be_empty
|
|
posts.each do |p|
|
|
expect(p['tags']).to be_an(Array)
|
|
p['tags'].each do |t|
|
|
expect(t).to include('name', 'category', 'has_wiki')
|
|
end
|
|
end
|
|
expect(json['count']).to be_an(Integer)
|
|
|
|
# spec_tag を含む投稿が存在すること
|
|
all_tag_names = posts.flat_map { |p| p["tags"].map { |t| t["name"] } }
|
|
expect(all_tag_names).to include("spec_tag")
|
|
end
|
|
|
|
context "when q is provided" do
|
|
it "filters posts by q (hit case)" do
|
|
get "/posts", params: { tags: "spec_tag" }
|
|
|
|
expect(response).to have_http_status(:ok)
|
|
posts = json.fetch('posts')
|
|
ids = posts.map { |p| p['id'] }
|
|
|
|
expect(ids).to include(hit_post.id)
|
|
expect(ids).not_to include(miss_post.id)
|
|
expect(json['count']).to be_an(Integer)
|
|
|
|
posts.each do |p|
|
|
expect(p['tags']).to be_an(Array)
|
|
p['tags'].each do |t|
|
|
expect(t).to include('name', 'category', 'has_wiki')
|
|
end
|
|
end
|
|
end
|
|
|
|
it "filters posts by q (hit case by alias)" do
|
|
get "/posts", params: { tags: "manko" }
|
|
|
|
expect(response).to have_http_status(:ok)
|
|
posts = json.fetch('posts')
|
|
ids = posts.map { |p| p['id'] }
|
|
|
|
expect(ids).to include(hit_post.id)
|
|
expect(ids).not_to include(miss_post.id)
|
|
expect(json['count']).to be_an(Integer)
|
|
|
|
posts.each do |p|
|
|
expect(p['tags']).to be_an(Array)
|
|
p['tags'].each do |t|
|
|
expect(t).to include('name', 'category', 'has_wiki')
|
|
end
|
|
end
|
|
end
|
|
|
|
it "returns empty posts when nothing matches" do
|
|
get "/posts", params: { tags: "no_such_keyword_12345" }
|
|
|
|
expect(response).to have_http_status(:ok)
|
|
expect(json.fetch("posts")).to eq([])
|
|
expect(json.fetch('count')).to eq(0)
|
|
end
|
|
end
|
|
|
|
context 'when tags contain not:' do
|
|
let!(:foo_tag_name) { TagName.create!(name: 'not_spec_foo') }
|
|
let!(:foo_tag) { Tag.create!(tag_name: foo_tag_name, category: :general) }
|
|
|
|
let!(:bar_tag_name) { TagName.create!(name: 'not_spec_bar') }
|
|
let!(:bar_tag) { Tag.create!(tag_name: bar_tag_name, category: :general) }
|
|
|
|
let!(:baz_tag_name) { TagName.create!(name: 'not_spec_baz') }
|
|
let!(:baz_tag) { Tag.create!(tag_name: baz_tag_name, category: :general) }
|
|
|
|
let!(:foo_alias_tag_name) do
|
|
TagName.create!(name: 'not_spec_foo_alias', canonical: foo_tag_name)
|
|
end
|
|
|
|
let!(:foo_only_post) do
|
|
Post.create!(uploaded_user: user, title: 'foo only',
|
|
url: 'https://example.com/not-spec-foo').tap do |p|
|
|
PostTag.create!(post: p, tag: foo_tag)
|
|
end
|
|
end
|
|
|
|
let!(:bar_only_post) do
|
|
Post.create!(uploaded_user: user, title: 'bar only',
|
|
url: 'https://example.com/not-spec-bar').tap do |p|
|
|
PostTag.create!(post: p, tag: bar_tag)
|
|
end
|
|
end
|
|
|
|
let!(:baz_only_post) do
|
|
Post.create!(uploaded_user: user, title: 'baz only',
|
|
url: 'https://example.com/not-spec-baz').tap do |p|
|
|
PostTag.create!(post: p, tag: baz_tag)
|
|
end
|
|
end
|
|
|
|
let!(:foo_bar_post) do
|
|
Post.create!(uploaded_user: user, title: 'foo bar',
|
|
url: 'https://example.com/not-spec-foo-bar').tap do |p|
|
|
PostTag.create!(post: p, tag: foo_tag)
|
|
PostTag.create!(post: p, tag: bar_tag)
|
|
end
|
|
end
|
|
|
|
let!(:foo_baz_post) do
|
|
Post.create!(uploaded_user: user, title: 'foo baz',
|
|
url: 'https://example.com/not-spec-foo-baz').tap do |p|
|
|
PostTag.create!(post: p, tag: foo_tag)
|
|
PostTag.create!(post: p, tag: baz_tag)
|
|
end
|
|
end
|
|
|
|
let(:controlled_ids) do
|
|
[foo_only_post.id, bar_only_post.id, baz_only_post.id,
|
|
foo_bar_post.id, foo_baz_post.id]
|
|
end
|
|
|
|
it 'supports not search' do
|
|
get '/posts', params: { tags: 'not:not_spec_foo' }
|
|
|
|
expect(response).to have_http_status(:ok)
|
|
|
|
expect(json.fetch('posts').map { |p| p['id'] } & controlled_ids).to match_array(
|
|
[bar_only_post.id, baz_only_post.id]
|
|
)
|
|
end
|
|
|
|
it 'supports alias in not search' do
|
|
get '/posts', params: { tags: 'not:not_spec_foo_alias' }
|
|
|
|
expect(response).to have_http_status(:ok)
|
|
|
|
expect(json.fetch('posts').map { |p| p['id'] } & controlled_ids).to match_array(
|
|
[bar_only_post.id, baz_only_post.id]
|
|
)
|
|
end
|
|
|
|
it 'treats multiple not terms as AND when match is omitted' do
|
|
get '/posts', params: { tags: 'not:not_spec_foo not:not_spec_bar' }
|
|
|
|
expect(response).to have_http_status(:ok)
|
|
|
|
expect(json.fetch('posts').map { |p| p['id'] } & controlled_ids).to match_array(
|
|
[baz_only_post.id]
|
|
)
|
|
end
|
|
|
|
it 'treats multiple not terms as OR when match=any' do
|
|
get '/posts', params: { tags: 'not:not_spec_foo not:not_spec_bar', match: 'any' }
|
|
|
|
expect(response).to have_http_status(:ok)
|
|
|
|
expect(json.fetch('posts').map { |p| p['id'] } & controlled_ids).to match_array(
|
|
[foo_only_post.id, bar_only_post.id, baz_only_post.id, foo_baz_post.id]
|
|
)
|
|
end
|
|
|
|
it 'supports mixed positive and negative search with AND' do
|
|
get '/posts', params: { tags: 'not_spec_foo not:not_spec_bar' }
|
|
|
|
expect(response).to have_http_status(:ok)
|
|
|
|
expect(json.fetch('posts').map { |p| p['id'] } & controlled_ids).to match_array(
|
|
[foo_only_post.id, foo_baz_post.id]
|
|
)
|
|
end
|
|
|
|
it 'supports mixed positive and negative search with OR when match=any' do
|
|
get '/posts', params: { tags: 'not_spec_foo not:not_spec_bar', match: 'any' }
|
|
|
|
expect(response).to have_http_status(:ok)
|
|
|
|
expect(json.fetch('posts').map { |p| p['id'] } & controlled_ids).to match_array(
|
|
[foo_only_post.id, baz_only_post.id, foo_bar_post.id, foo_baz_post.id]
|
|
)
|
|
end
|
|
end
|
|
|
|
context 'when url is provided' do
|
|
let!(:url_hit_post) do
|
|
Post.create!(uploaded_user: user, title: 'url hit',
|
|
url: 'https://example.com/needle-url-xyz').tap do |p|
|
|
PostTag.create!(post: p, tag:)
|
|
end
|
|
end
|
|
|
|
let!(:url_miss_post) do
|
|
Post.create!(uploaded_user: user, title: 'url miss',
|
|
url: 'https://example.com/other-url').tap do |p|
|
|
PostTag.create!(post: p, tag:)
|
|
end
|
|
end
|
|
|
|
it 'filters posts by url substring' do
|
|
get '/posts', params: { url: 'needle-url-xyz' }
|
|
|
|
expect(response).to have_http_status(:ok)
|
|
ids = json.fetch('posts').map { |p| p['id'] }
|
|
|
|
expect(ids).to include(url_hit_post.id)
|
|
expect(ids).not_to include(url_miss_post.id)
|
|
expect(json.fetch('count')).to eq(1)
|
|
end
|
|
end
|
|
|
|
context 'when title is provided' do
|
|
let!(:title_hit_post) do
|
|
Post.create!(uploaded_user: user, title: 'needle-title-xyz',
|
|
url: 'https://example.com/title-hit').tap do |p|
|
|
PostTag.create!(post: p, tag:)
|
|
end
|
|
end
|
|
|
|
let!(:title_miss_post) do
|
|
Post.create!(uploaded_user: user, title: 'other title',
|
|
url: 'https://example.com/title-miss').tap do |p|
|
|
PostTag.create!(post: p, tag:)
|
|
end
|
|
end
|
|
|
|
it 'filters posts by title substring' do
|
|
get '/posts', params: { title: 'needle-title-xyz' }
|
|
|
|
expect(response).to have_http_status(:ok)
|
|
ids = json.fetch('posts').map { |p| p['id'] }
|
|
|
|
expect(ids).to include(title_hit_post.id)
|
|
expect(ids).not_to include(title_miss_post.id)
|
|
expect(json.fetch('count')).to eq(1)
|
|
end
|
|
end
|
|
|
|
context 'when created_from/created_to are provided' do
|
|
let(:t_created_hit) { Time.zone.local(2010, 1, 5, 12, 0, 0) }
|
|
let(:t_created_miss) { Time.zone.local(2012, 1, 5, 12, 0, 0) }
|
|
|
|
let!(:created_hit_post) do
|
|
travel_to(t_created_hit) do
|
|
Post.create!(uploaded_user: user, title: 'created hit',
|
|
url: 'https://example.com/created-hit').tap do |p|
|
|
PostTag.create!(post: p, tag:)
|
|
end
|
|
end
|
|
end
|
|
|
|
let!(:created_miss_post) do
|
|
travel_to(t_created_miss) do
|
|
Post.create!(uploaded_user: user, title: 'created miss',
|
|
url: 'https://example.com/created-miss').tap do |p|
|
|
PostTag.create!(post: p, tag:)
|
|
end
|
|
end
|
|
end
|
|
|
|
it 'filters posts by created_at range' do
|
|
get '/posts', params: {
|
|
created_from: Time.zone.local(2010, 1, 1, 0, 0, 0).iso8601,
|
|
created_to: Time.zone.local(2010, 12, 31, 23, 59, 59).iso8601
|
|
}
|
|
|
|
expect(response).to have_http_status(:ok)
|
|
ids = json.fetch('posts').map { |p| p['id'] }
|
|
|
|
expect(ids).to include(created_hit_post.id)
|
|
expect(ids).not_to include(created_miss_post.id)
|
|
expect(json.fetch('count')).to eq(1)
|
|
end
|
|
end
|
|
|
|
context 'when updated_from/updated_to are provided' do
|
|
let(:t0) { Time.zone.local(2011, 2, 1, 12, 0, 0) }
|
|
let(:t1) { Time.zone.local(2011, 2, 10, 12, 0, 0) }
|
|
|
|
let!(:updated_hit_post) do
|
|
p = nil
|
|
travel_to(t0) do
|
|
p = Post.create!(uploaded_user: user, title: 'updated hit',
|
|
url: 'https://example.com/updated-hit').tap do |pp|
|
|
PostTag.create!(post: pp, tag:)
|
|
end
|
|
end
|
|
travel_to(t1) do
|
|
p.update!(title: 'updated hit v2')
|
|
end
|
|
p
|
|
end
|
|
|
|
let!(:updated_miss_post) do
|
|
travel_to(Time.zone.local(2013, 1, 1, 12, 0, 0)) do
|
|
Post.create!(uploaded_user: user, title: 'updated miss',
|
|
url: 'https://example.com/updated-miss').tap do |p|
|
|
PostTag.create!(post: p, tag:)
|
|
end
|
|
end
|
|
end
|
|
|
|
it 'filters posts by updated_at range' do
|
|
get '/posts', params: {
|
|
updated_from: Time.zone.local(2011, 2, 5, 0, 0, 0).iso8601,
|
|
updated_to: Time.zone.local(2011, 2, 20, 23, 59, 59).iso8601
|
|
}
|
|
|
|
expect(response).to have_http_status(:ok)
|
|
ids = json.fetch('posts').map { |p| p['id'] }
|
|
|
|
expect(ids).to include(updated_hit_post.id)
|
|
expect(ids).not_to include(updated_miss_post.id)
|
|
expect(json.fetch('count')).to eq(1)
|
|
end
|
|
end
|
|
|
|
context 'when original_created_from/original_created_to are provided' do
|
|
# 注意: controller の現状ロジックに合わせてる
|
|
# original_created_from は `original_created_before > ?`
|
|
# original_created_to は `original_created_from <= ?`
|
|
|
|
let!(:oc_hit_post) do
|
|
Post.create!(uploaded_user: user, title: 'oc hit',
|
|
url: 'https://example.com/oc-hit',
|
|
original_created_from: Time.zone.local(2015, 1, 1, 0, 0, 0),
|
|
original_created_before: Time.zone.local(2015, 1, 10, 0, 0, 0)).tap do |p|
|
|
PostTag.create!(post: p, tag:)
|
|
end
|
|
end
|
|
|
|
# original_created_from の条件は「original_created_before > param」なので、
|
|
# before が param 以下になるようにする(ただし before >= from は守る)
|
|
let!(:oc_miss_post_for_from) do
|
|
Post.create!(
|
|
uploaded_user: user,
|
|
title: 'oc miss for from',
|
|
url: 'https://example.com/oc-miss-from',
|
|
original_created_from: Time.zone.local(2014, 12, 1, 0, 0, 0),
|
|
original_created_before: Time.zone.local(2015, 1, 1, 0, 0, 0)
|
|
).tap { |p| PostTag.create!(post: p, tag:) }
|
|
end
|
|
|
|
# original_created_to の条件は「original_created_from <= param」なので、
|
|
# from が param より後になるようにする(before >= from は守る)
|
|
let!(:oc_miss_post_for_to) do
|
|
Post.create!(
|
|
uploaded_user: user,
|
|
title: 'oc miss for to',
|
|
url: 'https://example.com/oc-miss-to',
|
|
original_created_from: Time.zone.local(2015, 2, 1, 0, 0, 0),
|
|
original_created_before: Time.zone.local(2015, 2, 10, 0, 0, 0)
|
|
).tap { |p| PostTag.create!(post: p, tag:) }
|
|
end
|
|
|
|
it 'filters posts by original_created_from (current controller behavior)' do
|
|
get '/posts', params: {
|
|
original_created_from: Time.zone.local(2015, 1, 5, 0, 0, 0).iso8601
|
|
}
|
|
|
|
expect(response).to have_http_status(:ok)
|
|
ids = json.fetch('posts').map { |p| p['id'] }
|
|
|
|
expect(ids).to include(oc_hit_post.id)
|
|
expect(ids).not_to include(oc_miss_post_for_from.id)
|
|
expect(json.fetch('count')).to eq(2)
|
|
end
|
|
|
|
it 'filters posts by original_created_to (current controller behavior)' do
|
|
get '/posts', params: {
|
|
original_created_to: Time.zone.local(2015, 1, 15, 0, 0, 0).iso8601
|
|
}
|
|
|
|
expect(response).to have_http_status(:ok)
|
|
ids = json.fetch('posts').map { |p| p['id'] }
|
|
|
|
expect(ids).to include(oc_hit_post.id)
|
|
expect(ids).not_to include(oc_miss_post_for_to.id)
|
|
expect(json.fetch('count')).to eq(2)
|
|
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', 'has_wiki')
|
|
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
|
|
|
|
context 'when post has parent, child, and sibling posts' do
|
|
let!(:parent_post) do
|
|
create_parent_post!(
|
|
title: 'shared parent post',
|
|
url: 'https://example.com/shared-parent-post'
|
|
)
|
|
end
|
|
|
|
let!(:child_post) do
|
|
Post.create!(
|
|
title: 'child post',
|
|
url: 'https://example.com/show-child-post'
|
|
)
|
|
end
|
|
|
|
let!(:sibling_post) do
|
|
Post.create!(
|
|
title: 'sibling post',
|
|
url: 'https://example.com/show-sibling-post'
|
|
)
|
|
end
|
|
|
|
before do
|
|
PostImplication.create!(
|
|
post: post_record,
|
|
parent_post:
|
|
)
|
|
|
|
PostImplication.create!(
|
|
post: child_post,
|
|
parent_post: post_record
|
|
)
|
|
|
|
PostImplication.create!(
|
|
post: sibling_post,
|
|
parent_post:
|
|
)
|
|
end
|
|
|
|
it 'returns parent_posts, child_posts, and sibling_posts' do
|
|
get "/posts/#{post_record.id}"
|
|
|
|
expect(response).to have_http_status(:ok)
|
|
|
|
parent_ids = json.fetch('parent_posts').map { |p| p.fetch('id') }
|
|
child_ids = json.fetch('child_posts').map { |p| p.fetch('id') }
|
|
|
|
expect(parent_ids).to include(parent_post.id)
|
|
expect(child_ids).to include(child_post.id)
|
|
|
|
sibling_posts_by_parent = json.fetch('sibling_posts')
|
|
siblings = sibling_posts_by_parent.fetch(parent_post.id.to_s)
|
|
|
|
sibling_ids = siblings.map { |p| p.fetch('id') }
|
|
expect(sibling_ids).to include(post_record.id)
|
|
expect(sibling_ids).to include(sibling_post.id)
|
|
end
|
|
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) }
|
|
let!(:alias_tag_name) { TagName.create!(name: 'manko', canonical: tag_name) }
|
|
|
|
it '401 when not logged in' do
|
|
sign_out
|
|
post '/posts', params: post_write_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: post_write_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: post_write_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
|
|
|
|
it '201 and creates post + tags when member and tags have aliases' do
|
|
sign_in_as(member)
|
|
|
|
post '/posts', params: post_write_params(
|
|
title: 'new post',
|
|
url: 'https://example.com/new',
|
|
tags: 'manko', # 既存タグ名を投げる
|
|
thumbnail: dummy_upload
|
|
)
|
|
|
|
expect(response).to have_http_status(:created)
|
|
expect(json).to include('id', 'title', 'url')
|
|
|
|
# tags が name を含むこと(API 側の serialization が正しいこと)
|
|
names = json.fetch('tags').map { |t| t['name'] }
|
|
expect(names).to include('spec_tag')
|
|
expect(names).not_to include('manko')
|
|
end
|
|
|
|
context "when nico tag already exists in tags" do
|
|
before do
|
|
Tag.find_undiscard_or_create_by!(
|
|
tag_name: TagName.find_undiscard_or_create_by!(name: 'nico:nico_tag'),
|
|
category: :nico)
|
|
end
|
|
|
|
it 'return 400' do
|
|
sign_in_as(member)
|
|
|
|
post '/posts', params: post_write_params(
|
|
title: 'new post',
|
|
url: 'https://example.com/nico-tag-post',
|
|
tags: 'nico:nico_tag',
|
|
thumbnail: dummy_upload
|
|
)
|
|
|
|
expect(response).to have_http_status(:bad_request), response.body
|
|
end
|
|
end
|
|
|
|
context 'when url is blank' do
|
|
it 'returns 422' do
|
|
sign_in_as(member)
|
|
|
|
post '/posts', params: post_write_params(
|
|
title: 'new post',
|
|
url: ' ',
|
|
tags: 'spec_tag', # 既存タグ名を投げる
|
|
thumbnail: dummy_upload)
|
|
|
|
expect(response).to have_http_status(:unprocessable_entity)
|
|
end
|
|
end
|
|
|
|
context 'when url is invalid' do
|
|
it 'returns 422' do
|
|
sign_in_as(member)
|
|
|
|
post '/posts', params: post_write_params(
|
|
title: 'new post',
|
|
url: 'ぼざクリタグ広場',
|
|
tags: 'spec_tag', # 既存タグ名を投げる
|
|
thumbnail: dummy_upload)
|
|
|
|
expect(response).to have_http_status(:unprocessable_entity)
|
|
end
|
|
end
|
|
|
|
context 'when parent_post_ids is provided' do
|
|
let!(:parent_post_1) do
|
|
create_parent_post!(
|
|
title: 'parent post 1',
|
|
url: 'https://example.com/parent-post-1'
|
|
)
|
|
end
|
|
|
|
let!(:parent_post_2) do
|
|
create_parent_post!(
|
|
title: 'parent post 2',
|
|
url: 'https://example.com/parent-post-2'
|
|
)
|
|
end
|
|
|
|
it 'creates post implications for parent posts' do
|
|
sign_in_as(member)
|
|
|
|
expect {
|
|
post '/posts', params: {
|
|
title: 'child post',
|
|
url: 'https://example.com/child-post',
|
|
tags: 'spec_tag',
|
|
parent_post_ids: "#{parent_post_1.id} #{parent_post_2.id}",
|
|
thumbnail: dummy_upload }
|
|
}.to change(PostImplication, :count).by(2)
|
|
|
|
expect(response).to have_http_status(:created)
|
|
|
|
created_post = Post.find(json.fetch('id'))
|
|
expect(created_post.parent_posts.order(:id).pluck(:id)).to eq(
|
|
[parent_post_1.id, parent_post_2.id].sort
|
|
)
|
|
|
|
expect(PostImplication.exists?(
|
|
post_id: created_post.id,
|
|
parent_post_id: parent_post_1.id
|
|
)).to be(true)
|
|
|
|
expect(PostImplication.exists?(
|
|
post_id: created_post.id,
|
|
parent_post_id: parent_post_2.id
|
|
)).to be(true)
|
|
end
|
|
|
|
it 'deduplicates parent_post_ids' do
|
|
sign_in_as(member)
|
|
|
|
expect {
|
|
post '/posts', params: post_write_params(
|
|
title: 'dedup child post',
|
|
url: 'https://example.com/dedup-child-post',
|
|
tags: 'spec_tag',
|
|
parent_post_ids: "#{parent_post_1.id} #{parent_post_1.id}",
|
|
thumbnail: dummy_upload
|
|
)
|
|
}.to change(PostImplication, :count).by(1)
|
|
|
|
expect(response).to have_http_status(:created)
|
|
|
|
created_post = Post.find(json.fetch('id'))
|
|
expect(created_post.parent_posts.pluck(:id)).to eq([parent_post_1.id])
|
|
end
|
|
|
|
it 'records parent_post_ids in post version' do
|
|
sign_in_as(member)
|
|
|
|
post '/posts', params: post_write_params(
|
|
title: 'versioned child post',
|
|
url: 'https://example.com/versioned-child-post',
|
|
tags: 'spec_tag',
|
|
parent_post_ids: "#{parent_post_1.id} #{parent_post_2.id}",
|
|
thumbnail: dummy_upload
|
|
)
|
|
|
|
expect(response).to have_http_status(:created)
|
|
|
|
created_post = Post.find(json.fetch('id'))
|
|
version = PostVersion.find_by!(post: created_post, version_no: 1)
|
|
|
|
expect(version.parent_post_ids.split.map(&:to_i)).to eq(
|
|
[parent_post_1.id, parent_post_2.id].sort
|
|
)
|
|
end
|
|
end
|
|
|
|
context 'when parent_post_ids is missing' do
|
|
it 'returns 422' do
|
|
sign_in_as(member)
|
|
|
|
expect {
|
|
post '/posts', params: {
|
|
title: 'missing parent_post_ids',
|
|
url: 'https://example.com/missing-parent-post-ids',
|
|
tags: 'spec_tag',
|
|
thumbnail: dummy_upload }
|
|
}.not_to change(Post, :count)
|
|
|
|
expect(response).to have_http_status(:unprocessable_entity)
|
|
expect(json.fetch('errors')).to be_present
|
|
end
|
|
end
|
|
|
|
context 'when parent_post_ids includes invalid token' do
|
|
it 'returns 422 and does not create post' do
|
|
sign_in_as(member)
|
|
|
|
expect {
|
|
post '/posts', params: post_write_params(
|
|
title: 'invalid parent ids',
|
|
url: 'https://example.com/invalid-parent-ids',
|
|
tags: 'spec_tag',
|
|
parent_post_ids: 'abc',
|
|
thumbnail: dummy_upload
|
|
)
|
|
}.not_to change(Post, :count)
|
|
|
|
expect(response).to have_http_status(:unprocessable_entity)
|
|
expect(json.fetch('errors')).to be_present
|
|
end
|
|
end
|
|
|
|
context 'when parent_post_ids includes nonexistent post id' do
|
|
it 'returns 422 and does not create post implication' do
|
|
sign_in_as(member)
|
|
|
|
expect {
|
|
post '/posts', params: post_write_params(
|
|
title: 'missing parent post',
|
|
url: 'https://example.com/missing-parent-post',
|
|
tags: 'spec_tag',
|
|
parent_post_ids: '999999999',
|
|
thumbnail: dummy_upload
|
|
)
|
|
}.not_to change(PostImplication, :count)
|
|
|
|
expect(response).to have_http_status(:unprocessable_entity)
|
|
expect(json.fetch('errors')).to be_present
|
|
end
|
|
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: post_update_params(
|
|
post_record, 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: post_update_params(
|
|
post_record, 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: post_update_params(
|
|
post_record,
|
|
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)
|
|
|
|
names = json['tags'].map { |n| n['name'] }
|
|
expect(names).to include('spec_tag_2')
|
|
end
|
|
|
|
context "when nico tag already exists in tags" do
|
|
before do
|
|
Tag.find_undiscard_or_create_by!(
|
|
tag_name: TagName.find_undiscard_or_create_by!(name: 'nico:nico_tag'),
|
|
category: :nico)
|
|
end
|
|
|
|
it 'return 400' do
|
|
sign_in_as(member)
|
|
|
|
put "/posts/#{post_record.id}", params: post_update_params(
|
|
post_record,
|
|
title: 'updated title',
|
|
tags: 'nico:nico_tag')
|
|
|
|
expect(response).to have_http_status(:bad_request), response.body
|
|
end
|
|
end
|
|
|
|
context 'when parent_post_ids is provided' do
|
|
let!(:old_parent_post) do
|
|
create_parent_post!(
|
|
title: 'old parent post',
|
|
url: 'https://example.com/old-parent-post'
|
|
)
|
|
end
|
|
|
|
let!(:new_parent_post_1) do
|
|
create_parent_post!(
|
|
title: 'new parent post 1',
|
|
url: 'https://example.com/new-parent-post-1'
|
|
)
|
|
end
|
|
|
|
let!(:new_parent_post_2) do
|
|
create_parent_post!(
|
|
title: 'new parent post 2',
|
|
url: 'https://example.com/new-parent-post-2'
|
|
)
|
|
end
|
|
|
|
before do
|
|
PostImplication.create!(
|
|
post: post_record,
|
|
parent_post: old_parent_post
|
|
)
|
|
end
|
|
|
|
it 'replaces parent posts' do
|
|
sign_in_as(member)
|
|
|
|
put "/posts/#{post_record.id}", params: post_update_params(
|
|
post_record,
|
|
title: 'updated title',
|
|
tags: 'spec_tag',
|
|
parent_post_ids: "#{new_parent_post_1.id} #{new_parent_post_2.id}")
|
|
|
|
expect(response).to have_http_status(:ok)
|
|
|
|
expect(post_record.reload.parent_posts.order(:id).pluck(:id)).to eq(
|
|
[new_parent_post_1.id, new_parent_post_2.id].sort
|
|
)
|
|
|
|
expect(PostImplication.exists?(
|
|
post_id: post_record.id,
|
|
parent_post_id: old_parent_post.id
|
|
)).to be(false)
|
|
end
|
|
|
|
it 'clears parent posts when parent_post_ids is blank' do
|
|
sign_in_as(member)
|
|
|
|
put "/posts/#{post_record.id}", params: post_update_params(
|
|
post_record,
|
|
title: 'updated title',
|
|
tags: 'spec_tag',
|
|
parent_post_ids: ''
|
|
)
|
|
|
|
expect(response).to have_http_status(:ok)
|
|
expect(post_record.reload.parent_posts).to be_empty
|
|
end
|
|
|
|
it 'records changed parent_post_ids in post version' do
|
|
sign_in_as(member)
|
|
create_post_version_for!(post_record.reload)
|
|
|
|
put "/posts/#{post_record.id}", params: post_update_params(
|
|
post_record,
|
|
title: 'updated title',
|
|
tags: 'spec_tag',
|
|
parent_post_ids: "#{new_parent_post_1.id} #{new_parent_post_2.id}"
|
|
)
|
|
|
|
expect(response).to have_http_status(:ok)
|
|
|
|
version = post_record.reload.post_versions.order(:version_no).last
|
|
|
|
expect(version.version_no).to eq(2)
|
|
expect(version.parent_post_ids.split.map(&:to_i)).to eq(
|
|
[new_parent_post_1.id, new_parent_post_2.id].sort
|
|
)
|
|
end
|
|
end
|
|
|
|
context 'when parent_post_ids is missing' do
|
|
it 'returns 422' do
|
|
sign_in_as(member)
|
|
|
|
base_version = create_post_version_for!(post_record.reload)
|
|
|
|
put "/posts/#{post_record.id}", params: {
|
|
base_version_no: base_version.version_no,
|
|
title: 'updated title',
|
|
tags: 'spec_tag' }
|
|
|
|
expect(response).to have_http_status(:unprocessable_entity)
|
|
expect(json.fetch('errors')).to be_present
|
|
end
|
|
end
|
|
|
|
context 'when parent_post_ids includes invalid token' do
|
|
it 'returns 422 and does not change parent posts' do
|
|
sign_in_as(member)
|
|
|
|
parent_post = create_parent_post!(
|
|
title: 'valid parent post',
|
|
url: 'https://example.com/valid-parent-post'
|
|
)
|
|
|
|
PostImplication.create!(
|
|
post: post_record,
|
|
parent_post:
|
|
)
|
|
|
|
put "/posts/#{post_record.id}", params: post_update_params(
|
|
post_record,
|
|
title: 'updated title',
|
|
tags: 'spec_tag',
|
|
parent_post_ids: 'abc'
|
|
)
|
|
|
|
expect(response).to have_http_status(:unprocessable_entity)
|
|
expect(post_record.reload.parent_posts.pluck(:id)).to eq([parent_post.id])
|
|
end
|
|
end
|
|
|
|
context 'when parent_post_ids includes nonexistent post id' do
|
|
it 'returns 422 and does not change parent posts' do
|
|
sign_in_as(member)
|
|
|
|
parent_post = create_parent_post!(
|
|
title: 'existing parent post',
|
|
url: 'https://example.com/existing-parent-post'
|
|
)
|
|
|
|
PostImplication.create!(
|
|
post: post_record,
|
|
parent_post:
|
|
)
|
|
|
|
put "/posts/#{post_record.id}", params: post_update_params(
|
|
post_record,
|
|
title: 'updated title',
|
|
tags: 'spec_tag',
|
|
parent_post_ids: '999999999'
|
|
)
|
|
|
|
expect(response).to have_http_status(:unprocessable_entity)
|
|
expect(post_record.reload.parent_posts.pluck(:id)).to eq([parent_post.id])
|
|
end
|
|
end
|
|
|
|
context 'when parent_post_ids includes self id' do
|
|
it 'returns 422 and does not create self implication' do
|
|
sign_in_as(member)
|
|
|
|
put "/posts/#{post_record.id}", params: post_update_params(
|
|
post_record,
|
|
title: 'updated title',
|
|
tags: 'spec_tag',
|
|
parent_post_ids: post_record.id.to_s
|
|
)
|
|
|
|
expect(response).to have_http_status(:unprocessable_entity)
|
|
|
|
expect(PostImplication.exists?(
|
|
post_id: post_record.id,
|
|
parent_post_id: post_record.id
|
|
)).to be(false)
|
|
end
|
|
end
|
|
|
|
context 'with optimistic locking' do
|
|
let!(:no_deerjikist_tag) { Tag.no_deerjikist }
|
|
|
|
before do
|
|
PostTag.create!(post: post_record, tag: no_deerjikist_tag)
|
|
end
|
|
|
|
it '400 when base_version_no is missing without force' do
|
|
sign_in_as(member)
|
|
|
|
put "/posts/#{post_record.id}", params: post_write_params(
|
|
title: 'updated title',
|
|
tags: 'spec_tag')
|
|
|
|
expect(response).to have_http_status(:bad_request)
|
|
end
|
|
|
|
it '400 when force and merge are both true' do
|
|
sign_in_as(member)
|
|
|
|
put "/posts/#{post_record.id}", params: post_write_params(
|
|
title: 'updated title',
|
|
tags: 'spec_tag',
|
|
force: '1',
|
|
merge: '1')
|
|
|
|
expect(response).to have_http_status(:bad_request)
|
|
end
|
|
|
|
it '409 when scalar fields are changed both by current and incoming updates' do
|
|
sign_in_as(member)
|
|
|
|
base_version = create_post_version_for!(post_record.reload)
|
|
|
|
post_record.update!(title: 'updated by other user')
|
|
PostVersionRecorder.record!(
|
|
post: post_record.reload,
|
|
event_type: :update,
|
|
created_by_user: member)
|
|
|
|
put "/posts/#{post_record.id}", params: post_write_params(
|
|
base_version_no: base_version.version_no,
|
|
title: 'updated by me',
|
|
tags: "spec_tag #{Tag.no_deerjikist.name}")
|
|
|
|
expect(response).to have_http_status(:conflict)
|
|
|
|
expect(json.fetch('error')).to eq('conflict')
|
|
expect(json.fetch('base_version_no')).to eq(base_version.version_no)
|
|
expect(json.fetch('current_version_no')).to eq(2)
|
|
expect(json.fetch('mergeable')).to be(false)
|
|
|
|
conflict_fields = json.fetch('conflicts').map { |change| change.fetch('field') }
|
|
expect(conflict_fields).to include('title')
|
|
|
|
expect(post_record.reload.title).to eq('updated by other user')
|
|
end
|
|
|
|
it 'returns 409 with mergeable true when stale tag changes do not conflict but merge is not requested' do
|
|
sign_in_as(member)
|
|
|
|
base_version = create_post_version_for!(post_record.reload)
|
|
|
|
current_tag = Tag.find_or_create_by_tag_name!('current_added_tag', category: :general)
|
|
PostTag.create!(post: post_record, tag: current_tag, created_user: member)
|
|
|
|
PostVersionRecorder.record!(
|
|
post: post_record.reload,
|
|
event_type: :update,
|
|
created_by_user: member)
|
|
|
|
put "/posts/#{post_record.id}", params: post_write_params(
|
|
base_version_no: base_version.version_no,
|
|
title: post_record.title,
|
|
tags: "spec_tag #{Tag.no_deerjikist.name} incoming_added_tag")
|
|
|
|
expect(response).to have_http_status(:conflict)
|
|
|
|
expect(json.fetch('mergeable')).to be(true)
|
|
|
|
tag_change = json.fetch('changes').find { |change| change.fetch('field') == 'tag_names' }
|
|
expect(tag_change).to be_present
|
|
expect(tag_change.fetch('conflict')).to be(false)
|
|
expect(tag_change.fetch('added_by_current')).to include('current_added_tag')
|
|
expect(tag_change.fetch('added_by_me')).to include('incoming_added_tag')
|
|
end
|
|
|
|
it 'merges non-conflicting stale tag changes when merge is true' do
|
|
sign_in_as(member)
|
|
|
|
base_version = create_post_version_for!(post_record.reload)
|
|
|
|
current_tag = Tag.find_or_create_by_tag_name!('current_merge_tag', category: :general)
|
|
PostTag.create!(post: post_record, tag: current_tag, created_user: member)
|
|
|
|
PostVersionRecorder.record!(
|
|
post: post_record.reload,
|
|
event_type: :update,
|
|
created_by_user: member)
|
|
|
|
put "/posts/#{post_record.id}", params: post_write_params(
|
|
base_version_no: base_version.version_no,
|
|
title: post_record.title,
|
|
tags: "spec_tag #{Tag.no_deerjikist.name} incoming_merge_tag",
|
|
merge: '1')
|
|
|
|
expect(response).to have_http_status(:ok)
|
|
|
|
names = post_record.reload.tags.map(&:name)
|
|
|
|
expect(names).to include('spec_tag')
|
|
expect(names).to include(Tag.no_deerjikist.name)
|
|
expect(names).to include('current_merge_tag')
|
|
expect(names).to include('incoming_merge_tag')
|
|
end
|
|
|
|
it 'does not conflict when only nico tags changed after the base version' do
|
|
sign_in_as(member)
|
|
|
|
base_version = create_post_version_for!(post_record.reload)
|
|
|
|
nico_tag = create_nico_tag!('nico:optimistic_lock_nico')
|
|
PostTag.create!(post: post_record, tag: nico_tag, created_user: member)
|
|
|
|
PostVersionRecorder.record!(
|
|
post: post_record.reload,
|
|
event_type: :update,
|
|
created_by_user: member)
|
|
|
|
expect(post_record.reload.version_no).to eq(2)
|
|
|
|
put "/posts/#{post_record.id}", params: post_write_params(
|
|
base_version_no: base_version.version_no,
|
|
title: post_record.title,
|
|
tags: "spec_tag #{ Tag.no_deerjikist.name }")
|
|
|
|
expect(response).to have_http_status(:ok)
|
|
|
|
names = post_record.reload.tags.map(&:name)
|
|
|
|
expect(names).to include('spec_tag')
|
|
expect(names).to include(Tag.no_deerjikist.name)
|
|
expect(names).to include(nico_tag.name)
|
|
end
|
|
|
|
it 'keeps nico tags even when they are not included in PUT tags' do
|
|
sign_in_as(member)
|
|
|
|
nico_tag = create_nico_tag!('nico:readonly_update_nico')
|
|
PostTag.create!(post: post_record, tag: nico_tag, created_user: member)
|
|
|
|
base_version = create_post_version_for!(post_record.reload)
|
|
|
|
put "/posts/#{post_record.id}", params: post_write_params(
|
|
base_version_no: base_version.version_no,
|
|
title: 'updated title',
|
|
tags: "spec_tag #{ Tag.no_deerjikist.name }")
|
|
|
|
expect(response).to have_http_status(:ok)
|
|
|
|
names = post_record.reload.tags.map(&:name)
|
|
|
|
expect(names).to include('spec_tag')
|
|
expect(names).to include(Tag.no_deerjikist.name)
|
|
expect(names).to include(nico_tag.name)
|
|
end
|
|
|
|
it 'allows non-nico tags linked from nico tags to be removed by normal post update' do
|
|
sign_in_as(member)
|
|
|
|
nico_tag = create_nico_tag!('nico:relation_source')
|
|
linked_tag = Tag.find_or_create_by_tag_name!('relation_linked_tag', category: :general)
|
|
|
|
NicoTagRelation.create!(nico_tag:, tag: linked_tag)
|
|
PostTag.create!(post: post_record, tag: nico_tag, created_user: member)
|
|
PostTag.create!(post: post_record, tag: linked_tag, created_user: member)
|
|
|
|
base_version = create_post_version_for!(post_record.reload)
|
|
|
|
put "/posts/#{post_record.id}", params: post_write_params(
|
|
base_version_no: base_version.version_no,
|
|
title: post_record.title,
|
|
tags: "spec_tag #{ Tag.no_deerjikist.name }")
|
|
|
|
expect(response).to have_http_status(:ok)
|
|
|
|
names = post_record.reload.tags.map(&:name)
|
|
|
|
expect(names).to include(nico_tag.name)
|
|
expect(names).to include('spec_tag')
|
|
expect(names).to include(Tag.no_deerjikist.name)
|
|
expect(names).not_to include(linked_tag.name)
|
|
end
|
|
|
|
it 'force-updates stale posts without base_version_no' do
|
|
sign_in_as(member)
|
|
|
|
create_post_version_for!(post_record.reload)
|
|
|
|
post_record.update!(title: 'updated by other user')
|
|
PostVersionRecorder.record!(
|
|
post: post_record.reload,
|
|
event_type: :update,
|
|
created_by_user: member)
|
|
|
|
put "/posts/#{post_record.id}", params: post_write_params(
|
|
title: 'forced title',
|
|
tags: "spec_tag #{Tag.no_deerjikist.name}",
|
|
force: '1')
|
|
|
|
expect(response).to have_http_status(:ok)
|
|
expect(post_record.reload.title).to eq('forced title')
|
|
end
|
|
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
|
|
|
|
it 'filters history by tag' do
|
|
tn2 = TagName.create!(name: 'history_tag_hit')
|
|
tag2 = Tag.create!(tag_name: tn2, category: :general)
|
|
|
|
tn3 = TagName.create!(name: 'history_tag_miss')
|
|
tag3 = Tag.create!(tag_name: tn3, category: :general)
|
|
|
|
other_post = Post.create!(
|
|
title: 'other post',
|
|
url: 'https://example.com/history-other'
|
|
)
|
|
|
|
# hit: add
|
|
PostTag.create!(post: post_record, tag: tag2, created_user: member)
|
|
|
|
# hit: add + remove
|
|
pt2 = PostTag.create!(post: other_post, tag: tag2, created_user: member)
|
|
pt2.discard_by!(member)
|
|
|
|
# miss: add + remove
|
|
pt3 = PostTag.create!(post: post_record, tag: tag3, created_user: member)
|
|
pt3.discard_by!(member)
|
|
|
|
get '/posts/changes', params: { tag: tag2.id }
|
|
|
|
expect(response).to have_http_status(:ok)
|
|
expect(json).to include('changes', 'count')
|
|
expect(json['count']).to eq(3)
|
|
|
|
changes = json.fetch('changes')
|
|
|
|
expect(changes.map { |e| e.dig('tag', 'id') }.uniq).to eq([tag2.id])
|
|
expect(changes.map { |e| e['change_type'] }).to match_array(%w[add add remove])
|
|
expect(changes.map { |e| e.dig('post', 'id') }).to match_array([
|
|
post_record.id,
|
|
other_post.id,
|
|
other_post.id
|
|
])
|
|
end
|
|
|
|
it 'filters history by post and tag together' do
|
|
tn2 = TagName.create!(name: 'history_tag_combo_hit')
|
|
tag2 = Tag.create!(tag_name: tn2, category: :general)
|
|
|
|
tn3 = TagName.create!(name: 'history_tag_combo_miss')
|
|
tag3 = Tag.create!(tag_name: tn3, category: :general)
|
|
|
|
other_post = Post.create!(
|
|
title: 'other combo post',
|
|
url: 'https://example.com/history-combo-other'
|
|
)
|
|
|
|
# hit
|
|
PostTag.create!(post: post_record, tag: tag2, created_user: member)
|
|
|
|
# miss by post
|
|
pt2 = PostTag.create!(post: other_post, tag: tag2, created_user: member)
|
|
pt2.discard_by!(member)
|
|
|
|
# miss by tag
|
|
pt3 = PostTag.create!(post: post_record, tag: tag3, created_user: member)
|
|
pt3.discard_by!(member)
|
|
|
|
get '/posts/changes', params: { id: post_record.id, tag: tag2.id }
|
|
|
|
expect(response).to have_http_status(:ok)
|
|
expect(json).to include('changes', 'count')
|
|
expect(json['count']).to eq(1)
|
|
|
|
changes = json.fetch('changes')
|
|
expect(changes.size).to eq(1)
|
|
expect(changes[0]['change_type']).to eq('add')
|
|
expect(changes[0].dig('post', 'id')).to eq(post_record.id)
|
|
expect(changes[0].dig('tag', 'id')).to eq(tag2.id)
|
|
end
|
|
|
|
it 'returns empty history when tag does not match' do
|
|
tn2 = TagName.create!(name: 'history_tag_no_hit')
|
|
tag2 = Tag.create!(tag_name: tn2, category: :general)
|
|
|
|
get '/posts/changes', params: { tag: tag2.id }
|
|
|
|
expect(response).to have_http_status(:ok)
|
|
expect(json.fetch('changes')).to eq([])
|
|
expect(json.fetch('count')).to eq(0)
|
|
end
|
|
end
|
|
|
|
describe 'GET /posts/versions' do
|
|
let(:member) { create(:user, :member, name: 'version member') }
|
|
|
|
let(:t_v1) { Time.zone.local(2020, 1, 1, 12, 0, 0) }
|
|
let(:t_v2) { Time.zone.local(2020, 1, 2, 12, 0, 0) }
|
|
let(:t_other) { Time.zone.local(2020, 1, 3, 12, 0, 0) }
|
|
|
|
let(:oc_from) { Time.zone.local(2019, 12, 31, 0, 0, 0) }
|
|
let(:oc_before) { Time.zone.local(2020, 1, 1, 0, 0, 0) }
|
|
|
|
let!(:tag_name2) { TagName.create!(name: 'spec_tag_2') }
|
|
let!(:tag2) { Tag.create!(tag_name: tag_name2, category: :general) }
|
|
|
|
def snapshot_tags(post)
|
|
post.snapshot_tag_names.join(' ')
|
|
end
|
|
|
|
def create_post_version! post, version_no:, event_type:, created_by_user:, created_at:
|
|
PostVersion.create!(
|
|
post:,
|
|
version_no:,
|
|
event_type:,
|
|
title: post.title,
|
|
url: post.url,
|
|
thumbnail_base: post.thumbnail_base,
|
|
tags: snapshot_tags(post),
|
|
parent_post_ids: post.snapshot_parent_post_ids.join(' '),
|
|
original_created_from: post.original_created_from,
|
|
original_created_before: post.original_created_before,
|
|
created_at:,
|
|
created_by_user:
|
|
)
|
|
end
|
|
|
|
let!(:v1) do
|
|
travel_to(t_v1) do
|
|
create_post_version!(
|
|
post_record,
|
|
version_no: 1,
|
|
event_type: 'create',
|
|
created_by_user: member,
|
|
created_at: t_v1
|
|
)
|
|
end
|
|
end
|
|
|
|
let!(:v2) do
|
|
post_record.post_tags.kept.find_by!(tag: tag).discard_by!(member)
|
|
PostTag.create!(post: post_record, tag: tag2, created_user: member)
|
|
post_record.update!(
|
|
title: 'updated spec post',
|
|
original_created_from: oc_from,
|
|
original_created_before: oc_before
|
|
)
|
|
|
|
travel_to(t_v2) do
|
|
create_post_version!(
|
|
post_record.reload,
|
|
version_no: 2,
|
|
event_type: 'update',
|
|
created_by_user: member,
|
|
created_at: t_v2
|
|
)
|
|
end
|
|
end
|
|
|
|
let!(:other_post_version) do
|
|
other_post = Post.create!(
|
|
title: 'other versioned post',
|
|
url: 'https://example.com/other-versioned'
|
|
)
|
|
PostTag.create!(post: other_post, tag: tag)
|
|
|
|
travel_to(t_other) do
|
|
create_post_version!(
|
|
other_post,
|
|
version_no: 1,
|
|
event_type: 'create',
|
|
created_by_user: member,
|
|
created_at: t_other
|
|
)
|
|
end
|
|
end
|
|
|
|
it 'returns versions for the specified post in reverse chronological order' do
|
|
get '/posts/versions', params: { post: post_record.id }
|
|
|
|
expect(response).to have_http_status(:ok)
|
|
expect(json).to include('versions', 'count')
|
|
expect(json.fetch('count')).to eq(2)
|
|
|
|
versions = json.fetch('versions')
|
|
expect(versions.map { |v| v['post_id'] }.uniq).to eq([post_record.id])
|
|
expect(versions.map { |v| v['version_no'] }).to eq([2, 1])
|
|
|
|
latest = versions.first
|
|
expect(latest).to include(
|
|
'post_id' => post_record.id,
|
|
'version_no' => 2,
|
|
'event_type' => 'update',
|
|
'created_by_user' => {
|
|
'id' => member.id,
|
|
'name' => member.name
|
|
}
|
|
)
|
|
|
|
expect(latest.fetch('title')).to eq(
|
|
'current' => 'updated spec post',
|
|
'prev' => 'spec post'
|
|
)
|
|
expect(latest.fetch('url')).to eq(
|
|
'current' => 'https://example.com/spec',
|
|
'prev' => 'https://example.com/spec'
|
|
)
|
|
expect(latest.fetch('thumbnail')).to eq(
|
|
'current' => nil,
|
|
'prev' => nil
|
|
)
|
|
expect(latest.fetch('thumbnail_base')).to eq(
|
|
'current' => nil,
|
|
'prev' => nil
|
|
)
|
|
expect(latest.fetch('tags')).to include(
|
|
{ 'name' => 'spec_tag_2', 'type' => 'added' },
|
|
{ 'name' => 'spec_tag', 'type' => 'removed' }
|
|
)
|
|
expect(latest.fetch('original_created_from')).to eq(
|
|
'current' => oc_from.iso8601,
|
|
'prev' => nil
|
|
)
|
|
expect(latest.fetch('original_created_before')).to eq(
|
|
'current' => oc_before.iso8601,
|
|
'prev' => nil
|
|
)
|
|
expect(latest.fetch('created_at')).to eq(t_v2.iso8601)
|
|
|
|
first = versions.second
|
|
expect(first).to include(
|
|
'post_id' => post_record.id,
|
|
'version_no' => 1,
|
|
'event_type' => 'create',
|
|
'created_by_user' => {
|
|
'id' => member.id,
|
|
'name' => member.name
|
|
}
|
|
)
|
|
expect(first.fetch('title')).to eq(
|
|
'current' => 'spec post',
|
|
'prev' => nil
|
|
)
|
|
expect(first.fetch('tags')).to include(
|
|
{ 'name' => 'spec_tag', 'type' => 'added' }
|
|
)
|
|
expect(first.fetch('created_at')).to eq(t_v1.iso8601)
|
|
end
|
|
|
|
it 'filters versions by tag when the current snapshot includes the tag' do
|
|
get '/posts/versions', params: { post: post_record.id, tag: tag2.id }
|
|
|
|
expect(response).to have_http_status(:ok)
|
|
expect(json.fetch('count')).to eq(1)
|
|
|
|
versions = json.fetch('versions')
|
|
expect(versions.size).to eq(1)
|
|
expect(versions[0]['post_id']).to eq(post_record.id)
|
|
expect(versions[0]['version_no']).to eq(2)
|
|
expect(versions[0]['tags']).to include(
|
|
{ 'name' => 'spec_tag_2', 'type' => 'added' }
|
|
)
|
|
end
|
|
|
|
it 'filters versions by tag when the tag exists in either current or previous snapshot' do
|
|
get '/posts/versions', params: { post: post_record.id, tag: tag.id }
|
|
|
|
expect(response).to have_http_status(:ok)
|
|
expect(json.fetch('count')).to eq(2)
|
|
|
|
versions = json.fetch('versions')
|
|
expect(versions.map { |v| v['post_id'] }).to all(eq(post_record.id))
|
|
expect(versions.map { |v| v['version_no'] }).to eq([2, 1])
|
|
|
|
latest = versions[0]
|
|
first = versions[1]
|
|
|
|
expect(latest['tags']).to include(
|
|
{ 'name' => 'spec_tag', 'type' => 'removed' }
|
|
)
|
|
expect(first['tags']).to include(
|
|
{ 'name' => 'spec_tag', 'type' => 'added' }
|
|
)
|
|
end
|
|
|
|
it 'returns empty when tag does not exist' do
|
|
get '/posts/versions', params: { tag: 999_999_999 }
|
|
|
|
expect(response).to have_http_status(:ok)
|
|
expect(json.fetch('versions')).to eq([])
|
|
expect(json.fetch('count')).to eq(0)
|
|
end
|
|
|
|
it 'clamps page and limit to at least 1' do
|
|
get '/posts/versions', params: { post: post_record.id, page: 0, limit: 0 }
|
|
|
|
expect(response).to have_http_status(:ok)
|
|
expect(json.fetch('count')).to eq(2)
|
|
|
|
versions = json.fetch('versions')
|
|
expect(versions.size).to eq(1)
|
|
expect(versions[0]['version_no']).to eq(2)
|
|
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/viewed' 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
|
|
|
|
describe 'post versioning' do
|
|
let(:member) { create(:user, :member) }
|
|
|
|
def snapshot_tags(post)
|
|
post.snapshot_tag_names.join(' ')
|
|
end
|
|
|
|
it 'creates version 1 on POST /posts' do
|
|
sign_in_as(member)
|
|
|
|
expect do
|
|
post '/posts', params: post_write_params(
|
|
title: 'versioned post',
|
|
url: 'https://example.com/versioned-post',
|
|
tags: 'spec_tag',
|
|
thumbnail: dummy_upload)
|
|
end.to change(PostVersion, :count).by(1)
|
|
|
|
expect(response).to have_http_status(:created)
|
|
|
|
created_post = Post.find(json.fetch('id'))
|
|
version = PostVersion.find_by!(post: created_post, version_no: 1)
|
|
|
|
expect(version.event_type).to eq('create')
|
|
expect(version.title).to eq('versioned post')
|
|
expect(version.url).to eq('https://example.com/versioned-post')
|
|
expect(version.created_by_user_id).to eq(member.id)
|
|
expect(version.tags).to eq(snapshot_tags(created_post))
|
|
end
|
|
|
|
it 'creates next version on PUT /posts/:id when snapshot changes' do
|
|
sign_in_as(member)
|
|
base_version = create_post_version_for!(post_record)
|
|
|
|
tag_name2 = TagName.create!(name: 'spec_tag_2')
|
|
Tag.create!(tag_name: tag_name2, category: :general)
|
|
|
|
expect do
|
|
put "/posts/#{post_record.id}", params: post_write_params(
|
|
base_version_no: base_version.version_no,
|
|
title: 'updated title',
|
|
tags: 'spec_tag_2')
|
|
end.to change(PostVersion, :count).by(1)
|
|
|
|
expect(response).to have_http_status(:ok)
|
|
|
|
version = post_record.reload.post_versions.order(:version_no).last
|
|
expect(version.version_no).to eq(2)
|
|
expect(version.event_type).to eq('update')
|
|
expect(version.title).to eq('updated title')
|
|
expect(version.created_by_user_id).to eq(member.id)
|
|
expect(version.tags).to eq(snapshot_tags(post_record.reload))
|
|
end
|
|
|
|
it 'does not create a new version on PUT /posts/:id when snapshot is unchanged' do
|
|
sign_in_as(member)
|
|
|
|
PostTag.create!(post: post_record, tag: Tag.no_deerjikist)
|
|
base_version = create_post_version_for!(post_record.reload)
|
|
|
|
expect {
|
|
put "/posts/#{post_record.id}", params: post_write_params(
|
|
base_version_no: base_version.version_no,
|
|
title: post_record.title,
|
|
tags: 'spec_tag')
|
|
}.not_to change(PostVersion, :count)
|
|
|
|
expect(response).to have_http_status(:ok)
|
|
|
|
version = post_record.reload.post_versions.order(:version_no).last
|
|
expect(version.version_no).to eq(1)
|
|
expect(version.event_type).to eq('create')
|
|
expect(version.tags).to eq(snapshot_tags(post_record))
|
|
end
|
|
|
|
it 'does not create a version when POST /posts is invalid' do
|
|
sign_in_as(member)
|
|
|
|
expect do
|
|
post '/posts', params: post_write_params(
|
|
title: 'invalid post',
|
|
url: 'ぼざクリタグ広場',
|
|
tags: 'spec_tag',
|
|
thumbnail: dummy_upload)
|
|
end.not_to change(PostVersion, :count)
|
|
|
|
expect(response).to have_http_status(:unprocessable_entity)
|
|
end
|
|
|
|
it 'does not create a version when PUT /posts/:id is invalid' do
|
|
sign_in_as(member)
|
|
base_version = create_post_version_for!(post_record)
|
|
|
|
expect do
|
|
put "/posts/#{post_record.id}", params: post_write_params(
|
|
base_version_no: base_version.version_no,
|
|
title: 'updated title',
|
|
tags: 'spec_tag',
|
|
original_created_from: Time.zone.local(2020, 1, 2, 0, 0, 0).iso8601,
|
|
original_created_before: Time.zone.local(2020, 1, 1, 0, 0, 0).iso8601)
|
|
end.not_to change(PostVersion, :count)
|
|
|
|
expect(response).to have_http_status(:unprocessable_entity)
|
|
end
|
|
end
|
|
|
|
describe 'tag versioning from post write actions' do
|
|
let(:member) { create(:user, :member) }
|
|
|
|
it 'creates tag snapshot for normalised tags on PUT /posts/:id' do
|
|
sign_in_as(member)
|
|
|
|
base_version = create_post_version_for!(post_record.reload)
|
|
|
|
tag_name2 = TagName.create!(name: 'spec_tag_2')
|
|
tag2 = Tag.create!(tag_name: tag_name2, category: :general)
|
|
|
|
expect {
|
|
put "/posts/#{post_record.id}", params: post_write_params(
|
|
base_version_no: base_version.version_no,
|
|
title: 'updated title',
|
|
tags: 'spec_tag_2')
|
|
}.to change { tag2.reload.tag_versions.count }.by(1)
|
|
|
|
expect(response).to have_http_status(:ok), response.body
|
|
end
|
|
end
|
|
end
|