タグ一覧ページの作成(#61) (#298)

#61

#61

Merge remote-tracking branch 'origin/main' into feature/061

#61

#61

#61

#61

#61

#61

#61

#61

#61

#61

日づけ不詳の表示修正

Co-authored-by: miteruzo <miteruzo@naver.com>
Reviewed-on: #298
This commit was merged in pull request #298.
This commit is contained in:
2026-03-21 19:58:02 +09:00
parent 8cf7107445
commit ee93ff8ea0
26 changed files with 1135 additions and 283 deletions
+7 -12
View File
@@ -75,7 +75,7 @@ class PostsController < ApplicationController
else
"posts.#{ order[0] }"
end
posts = q.order(Arel.sql("#{ sort_sql } #{ order[1] }, id #{ order[1] }"))
posts = q.order(Arel.sql("#{ sort_sql } #{ order[1] }, posts.id #{ order[1] }"))
.limit(limit)
.offset(offset)
.to_a
@@ -100,23 +100,16 @@ class PostsController < ApplicationController
.first
return head :not_found unless post
viewed = current_user&.viewed?(post) || false
render json: PostRepr.base(post).merge(viewed:)
render json: PostRepr.base(post, current_user)
end
def show
post = Post.includes(tags: { tag_name: :wiki_page }).find_by(id: params[:id])
return head :not_found unless post
viewed = current_user&.viewed?(post) || false
json = post.as_json
json['tags'] = build_tag_tree_for(post.tags)
json['related'] = post.related(limit: 20)
json['viewed'] = viewed
render json:
render json: PostRepr.base(post, current_user)
.merge(tags: build_tag_tree_for(post.tags),
related: post.related(limit: 20))
end
def create
@@ -192,6 +185,7 @@ class PostsController < ApplicationController
def changes
id = params[:id].presence
tag_id = params[:tag].presence
page = (params[:page].presence || 1).to_i
limit = (params[:limit].presence || 20).to_i
@@ -202,6 +196,7 @@ class PostsController < ApplicationController
pts = PostTag.with_discarded
pts = pts.where(post_id: id) if id.present?
pts = pts.where(tag_id:) if tag_id.present?
pts = pts.includes(:post, :created_user, :deleted_user,
tag: { tag_name: :wiki_page })
+58 -5
View File
@@ -2,18 +2,71 @@ class TagsController < ApplicationController
def index
post_id = params[:post]
tags =
name = params[:name].presence
category = params[:category].presence
post_count_between = (params[:post_count_gte].presence || -1).to_i,
(params[:post_count_lte].presence || -1).to_i
post_count_between[0] = nil if post_count_between[0] < 0
post_count_between[1] = nil if post_count_between[1] < 0
created_between = params[:created_from].presence, params[:created_to].presence
updated_between = params[:updated_from].presence, params[:updated_to].presence
order = params[:order].to_s.split(':', 2).map(&:strip)
unless order[0].in?(['name', 'category', 'post_count', 'created_at', 'updated_at'])
order[0] = 'post_count'
end
unless order[1].in?(['asc', 'desc'])
order[1] = order[0].in?(['name', 'category']) ? 'asc' : 'desc'
end
page = (params[:page].presence || 1).to_i
limit = (params[:limit].presence || 20).to_i
page = 1 if page < 1
limit = 1 if limit < 1
offset = (page - 1) * limit
q =
if post_id.present?
Tag.joins(:posts, :tag_name)
else
Tag.joins(:tag_name)
end
.includes(:tag_name, tag_name: :wiki_page)
if post_id.present?
tags = tags.where(posts: { id: post_id })
end
q = q.where(posts: { id: post_id }) if post_id.present?
render json: TagRepr.base(tags)
q = q.where('tag_names.name LIKE ?', "%#{ name }%") if name
q = q.where(category: category) if category
q = q.where('tags.post_count >= ?', post_count_between[0]) if post_count_between[0]
q = q.where('tags.post_count <= ?', post_count_between[1]) if post_count_between[1]
q = q.where('tags.created_at >= ?', created_between[0]) if created_between[0]
q = q.where('tags.created_at <= ?', created_between[1]) if created_between[1]
q = q.where('tags.updated_at >= ?', updated_between[0]) if updated_between[0]
q = q.where('tags.updated_at <= ?', updated_between[1]) if updated_between[1]
sort_sql =
case order[0]
when 'name'
'tag_names.name'
when 'category'
'CASE tags.category ' +
"WHEN 'deerjikist' THEN 0 " +
"WHEN 'meme' THEN 1 " +
"WHEN 'character' THEN 2 " +
"WHEN 'general' THEN 3 " +
"WHEN 'material' THEN 4 " +
"WHEN 'meta' THEN 5 " +
"WHEN 'nico' THEN 6 END"
else
"tags.#{ order[0] }"
end
tags = q.order(Arel.sql("#{ sort_sql } #{ order[1] }, tags.id #{ order[1] }"))
.limit(limit)
.offset(offset)
.to_a
render json: { tags: TagRepr.base(tags), count: q.size }
end
def autocomplete
+9 -5
View File
@@ -2,15 +2,19 @@
module PostRepr
BASE = { include: { tags: TagRepr::BASE } }.freeze
BASE = { include: { tags: TagRepr::BASE, uploaded_user: UserRepr::BASE } }.freeze
module_function
def base post
post.as_json(BASE)
def base post, current_user = nil
json = post.as_json(BASE)
return json.merge(viewed: false) unless current_user
viewed = current_user.viewed?(post)
json.merge(viewed:)
end
def many posts
posts.map { |p| base(p) }
def many posts, current_user = nil
posts.map { |p| base(p, current_user) }
end
end
+2 -1
View File
@@ -2,7 +2,8 @@
module TagRepr
BASE = { only: [:id, :category, :post_count], methods: [:name, :has_wiki] }.freeze
BASE = { only: [:id, :category, :post_count, :created_at, :updated_at],
methods: [:name, :has_wiki] }.freeze
module_function
+16
View File
@@ -0,0 +1,16 @@
# frozen_string_literal: true
module UserRepr
BASE = { only: [:id, :name] }.freeze
module_function
def base user
user.as_json(BASE)
end
def many users
users.map { |u| base(u) }
end
end
+87
View File
@@ -667,6 +667,93 @@ RSpec.describe 'Posts API', type: :request do
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 'POST /posts/:id/viewed' do
+219 -55
View File
@@ -11,16 +11,180 @@ RSpec.describe 'Tags API', type: :request do
let!(:tn2) { TagName.create!(name: 'unknown') }
let!(:tag2) { Tag.create!(tag_name: tn2, category: :general) }
def response_tags
json.fetch('tags')
end
def response_names
response_tags.map { |t| t.fetch('name') }
end
describe 'GET /tags' do
it 'returns tags with name' do
it 'returns tags with count and metadata' do
get '/tags'
expect(response).to have_http_status(:ok)
expect(json).to include('tags', 'count')
expect(response_tags).to be_an(Array)
expect(json['count']).to be_an(Integer)
expect(json['count']).to be >= response_tags.size
expect(json).to be_an(Array)
expect(json).not_to be_empty
expect(json[0]).to have_key('name')
expect(json.map { |t| t['name'] }).to include('spec_tag')
row = response_tags.find { |t| t['name'] == 'spec_tag' }
expect(row).to include(
'id' => tag.id,
'name' => 'spec_tag',
'category' => 'general',
'post_count' => 1,
'has_wiki' => false)
expect(row).to have_key('created_at')
expect(row).to have_key('updated_at')
end
it 'filters tags by post id' do
get '/tags', params: { post: post.id }
expect(response).to have_http_status(:ok)
expect(json['count']).to eq(1)
expect(response_names).to eq(['spec_tag'])
end
it 'filters tags by partial name' do
get '/tags', params: { name: 'spec' }
expect(response).to have_http_status(:ok)
expect(response_names).to include('spec_tag')
expect(response_names).not_to include('unknown')
end
it 'filters tags by category' do
meme = Tag.create!(tag_name: TagName.create!(name: 'meme_only'), category: :meme)
get '/tags', params: { category: 'meme' }
expect(response).to have_http_status(:ok)
expect(response_names).to eq(['meme_only'])
expect(response_tags.first['id']).to eq(meme.id)
end
it 'filters tags by post_count range' do
low = Tag.create!(tag_name: TagName.create!(name: 'pc_low'), category: :general)
mid = Tag.create!(tag_name: TagName.create!(name: 'pc_mid'), category: :general)
high = Tag.create!(tag_name: TagName.create!(name: 'pc_high'), category: :general)
low.update_columns(post_count: 1)
mid.update_columns(post_count: 3)
high.update_columns(post_count: 5)
get '/tags', params: {
name: 'pc_',
post_count_gte: 2,
post_count_lte: 4,
order: 'post_count:asc',
}
expect(response).to have_http_status(:ok)
expect(response_names).to eq(['pc_mid'])
end
it 'filters tags by created_at range' do
old_tag = Tag.create!(tag_name: TagName.create!(name: 'created_old'), category: :general)
new_tag = Tag.create!(tag_name: TagName.create!(name: 'created_new'), category: :general)
old_time = Time.zone.local(2024, 1, 1, 0, 0, 0)
new_time = Time.zone.local(2024, 2, 1, 0, 0, 0)
old_tag.update_columns(created_at: old_time, updated_at: old_time)
new_tag.update_columns(created_at: new_time, updated_at: new_time)
get '/tags', params: {
name: 'created_',
created_from: Time.zone.local(2024, 1, 15, 0, 0, 0).iso8601,
order: 'created_at:asc',
}
expect(response).to have_http_status(:ok)
expect(response_names).to eq(['created_new'])
end
it 'filters tags by updated_at range' do
old_tag = Tag.create!(tag_name: TagName.create!(name: 'updated_old'), category: :general)
new_tag = Tag.create!(tag_name: TagName.create!(name: 'updated_new'), category: :general)
old_time = Time.zone.local(2024, 3, 1, 0, 0, 0)
new_time = Time.zone.local(2024, 4, 1, 0, 0, 0)
old_tag.update_columns(created_at: old_time, updated_at: old_time)
new_tag.update_columns(created_at: new_time, updated_at: new_time)
get '/tags', params: {
name: 'updated_',
updated_to: Time.zone.local(2024, 3, 15, 0, 0, 0).iso8601,
order: 'updated_at:asc',
}
expect(response).to have_http_status(:ok)
expect(response_names).to eq(['updated_old'])
end
it 'orders tags by custom category order' do
Tag.create!(tag_name: TagName.create!(name: 'cat_deerjikist'), category: :deerjikist)
Tag.create!(tag_name: TagName.create!(name: 'cat_meme'), category: :meme)
Tag.create!(tag_name: TagName.create!(name: 'cat_character'), category: :character)
Tag.create!(tag_name: TagName.create!(name: 'cat_general'), category: :general)
Tag.create!(tag_name: TagName.create!(name: 'cat_material'), category: :material)
Tag.create!(tag_name: TagName.create!(name: 'cat_meta'), category: :meta)
Tag.create!(tag_name: TagName.create!(name: 'nico:cat_nico'), category: :nico)
get '/tags', params: { name: 'cat_', order: 'category:asc', limit: 20 }
expect(response).to have_http_status(:ok)
expect(response_names).to eq(%w[
cat_deerjikist
cat_meme
cat_character
cat_general
cat_material
cat_meta
nico:cat_nico
])
end
it 'paginates and keeps total count' do
%w[pag_a pag_b pag_c].each do |name|
Tag.create!(tag_name: TagName.create!(name:), category: :general)
end
get '/tags', params: { name: 'pag_', order: 'name:asc', page: 2, limit: 2 }
expect(response).to have_http_status(:ok)
expect(json['count']).to eq(3)
expect(response_names).to eq(%w[pag_c])
end
it 'falls back to default ordering when order is invalid' do
low = Tag.create!(tag_name: TagName.create!(name: 'fallback_low'), category: :general)
high = Tag.create!(tag_name: TagName.create!(name: 'fallback_high'), category: :general)
low.update_columns(post_count: 1)
high.update_columns(post_count: 9)
get '/tags', params: { name: 'fallback_', order: 'nope:sideways' }
expect(response).to have_http_status(:ok)
expect(response_names.first).to eq('fallback_high')
end
it 'normalises invalid page and limit' do
%w[norm_a norm_b].each do |name|
Tag.create!(tag_name: TagName.create!(name:), category: :general)
end
get '/tags', params: { name: 'norm_', order: 'name:asc', page: 0, limit: 0 }
expect(response).to have_http_status(:ok)
expect(json['count']).to eq(2)
expect(response_tags.size).to eq(1)
expect(response_names).to eq(['norm_a'])
end
end
@@ -37,9 +201,13 @@ RSpec.describe 'Tags API', type: :request do
expect(response).to have_http_status(:ok)
expect(json).to include(
'id' => tag.id,
'name' => 'spec_tag',
'category' => 'general')
'id' => tag.id,
'name' => 'spec_tag',
'category' => 'general',
'post_count' => 1,
'has_wiki' => false)
expect(json).to have_key('created_at')
expect(json).to have_key('updated_at')
end
end
@@ -61,7 +229,7 @@ RSpec.describe 'Tags API', type: :request do
expect(json).to be_an(Array)
expect(json.map { |t| t['name'] }).to include('spec_tag')
t = json.find { |t| t['name'] == 'spec_tag' }
t = json.find { |x| x['name'] == 'spec_tag' }
expect(t).to have_key('matched_alias')
expect(t['matched_alias']).to be(nil)
end
@@ -73,9 +241,9 @@ RSpec.describe 'Tags API', type: :request do
expect(json).to be_an(Array)
expect(json.map { |t| t['name'] }).to include('spec_tag')
t = json.find { |t| t['name'] == 'spec_tag' }
t = json.find { |x| x['name'] == 'spec_tag' }
expect(t['matched_alias']).to eq('unko')
expect(json.map { |t| t['name'] }).not_to include('unknown')
expect(json.map { |x| x['name'] }).not_to include('unknown')
end
end
@@ -85,10 +253,14 @@ RSpec.describe 'Tags API', type: :request do
expect(response).to have_http_status(:ok)
expect(json).to have_key('id')
expect(json).to have_key('name')
expect(json['id']).to eq(tag.id)
expect(json['name']).to eq('spec_tag')
expect(json).to include(
'id' => tag.id,
'name' => 'spec_tag',
'category' => 'general',
'post_count' => 1,
'has_wiki' => false)
expect(json).to have_key('created_at')
expect(json).to have_key('updated_at')
end
it 'returns 404 when not found' do
@@ -97,7 +269,6 @@ RSpec.describe 'Tags API', type: :request do
end
end
# member? を持つ user を想定(Factory 側で trait 作ってもOK
let(:member_user) { create(:user) }
let(:non_member_user) { create(:user) }
@@ -110,87 +281,80 @@ RSpec.describe 'Tags API', type: :request do
allow(non_member_user).to receive(:gte_member?).and_return(false)
end
describe "PATCH /tags/:id" do
context "未ログイン" do
describe 'PATCH /tags/:id' do
context '未ログイン' do
before { stub_current_user(nil) }
it "401 を返す" do
patch "/tags/#{tag.id}", params: { name: "new" }
it '401 を返す' do
patch "/tags/#{ tag.id }", params: { name: 'new' }
expect(response).to have_http_status(:unauthorized)
end
end
context "ログインしてゐるが member でない" do
context 'ログインしてゐるが member でない' do
before { stub_current_user(non_member_user) }
it "403 を返す" do
patch "/tags/#{tag.id}", params: { name: "new" }
it '403 を返す' do
patch "/tags/#{ tag.id }", params: { name: 'new' }
expect(response).to have_http_status(:forbidden)
end
end
context "member" do
context 'member' do
before { stub_current_user(member_user) }
it "name だけ更新できる" do
patch "/tags/#{tag.id}", params: { name: "new" }
it 'name だけ更新できる' do
patch "/tags/#{ tag.id }", params: { name: 'new' }
expect(response).to have_http_status(:ok)
tag.reload
expect(tag.name).to eq("new")
expect(tag.category).to eq("general")
expect(tag.name).to eq('new')
expect(tag.category).to eq('general')
json = JSON.parse(response.body)
expect(json["id"]).to eq(tag.id)
expect(json["name"]).to eq("new")
expect(json["category"]).to eq("general")
body = JSON.parse(response.body)
expect(body['id']).to eq(tag.id)
expect(body['name']).to eq('new')
expect(body['category']).to eq('general')
end
it "category だけ更新できる" do
patch "/tags/#{tag.id}", params: { category: "meme" }
it 'category だけ更新できる' do
patch "/tags/#{ tag.id }", params: { category: 'meme' }
expect(response).to have_http_status(:ok)
tag.reload
expect(tag.name).to eq("spec_tag")
expect(tag.category).to eq("meme")
expect(tag.name).to eq('spec_tag')
expect(tag.category).to eq('meme')
end
it "空文字は presence により無視され、更新は走らない(値が変わらない)" do
patch "/tags/#{tag.id}", params: { name: "", category: " " }
it '空文字は presence により無視され、更新は走らない(値が変わらない)' do
patch "/tags/#{ tag.id }", params: { name: '', category: ' ' }
expect(response).to have_http_status(:ok)
tag.reload
expect(tag.name).to eq("spec_tag")
expect(tag.category).to eq("general")
expect(tag.name).to eq('spec_tag')
expect(tag.category).to eq('general')
end
it "両方更新できる" do
patch "/tags/#{tag.id}", params: { name: "n", category: "meta" }
it '両方更新できる' do
patch "/tags/#{ tag.id }", params: { name: 'n', category: 'meta' }
expect(response).to have_http_status(:ok)
tag.reload
expect(tag.name).to eq("n")
expect(tag.category).to eq("meta")
expect(tag.name).to eq('n')
expect(tag.category).to eq('meta')
end
it "存在しない id だと RecordNotFound になる(通常は 404" do
# Rails 設定次第で例外がそのまま上がる/404になる
# APIなら rescue_from で 404 にしてることが多いので、その場合は 404 を期待。
patch "/tags/999999999", params: { name: "x" }
it '存在しない id だと RecordNotFound になる(通常は 404' do
patch '/tags/999999999', params: { name: 'x' }
expect(response.status).to be_in([404, 500])
end
it "バリデーションで update! が失敗したら(通常は 422 か 500)" do
patch "/tags/#{tag.id}", params: { name: 'new', category: 'nico' }
# rescue_from の実装次第で変はる:
# - RecordInvalid を 422 にしてるなら 422
# - 未処理なら 500
it 'バリデーションで update! が失敗したら(通常は 422 か 500)' do
patch "/tags/#{ tag.id }", params: { name: 'new', category: 'nico' }
expect(response.status).to be_in([422, 500])
end
end