From 663206c14c87e3f715ce4aaa068c91954f7aec46 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Thu, 26 Feb 2026 06:48:28 +0900 Subject: [PATCH] #206 --- backend/app/controllers/posts_controller.rb | 60 +++--- backend/spec/requests/posts_spec.rb | 201 +++++++++++++++++++- frontend/src/pages/posts/PostSearchPage.tsx | 26 +-- 3 files changed, 245 insertions(+), 42 deletions(-) diff --git a/backend/app/controllers/posts_controller.rb b/backend/app/controllers/posts_controller.rb index 33fef9a..34cb37d 100644 --- a/backend/app/controllers/posts_controller.rb +++ b/backend/app/controllers/posts_controller.rb @@ -2,38 +2,46 @@ class PostsController < ApplicationController Event = Struct.new(:post, :tag, :user, :change_type, :timestamp, keyword_init: true) def index + url = params[:url].presence + title = params[:title].presence + original_created_from = params[:original_created_from].presence + original_created_to = params[:original_created_to].presence + created_between = params[:created_from].presence, params[:created_to].presence + updated_between = params[:updated_from].presence, params[:updated_to].presence + page = (params[:page].presence || 1).to_i limit = (params[:limit].presence || 20).to_i - cursor = params[:cursor].presence page = 1 if page < 1 limit = 1 if limit < 1 offset = (page - 1) * limit - sort_sql = - 'COALESCE(posts.original_created_before - INTERVAL 1 SECOND,' + - 'posts.original_created_from,' + - 'posts.created_at)' q = filtered_posts .preload(tags: { tag_name: :wiki_page }) .with_attached_thumbnail - .select("posts.*, #{ sort_sql } AS sort_ts") - .order(Arel.sql("#{ sort_sql } DESC")) - posts = - if cursor - q.where("#{ sort_sql } < ?", Time.iso8601(cursor)).limit(limit + 1) - else - q.limit(limit).offset(offset) - end - .to_a - - next_cursor = nil - if cursor && posts.length > limit - next_cursor = posts.last.read_attribute('sort_ts').iso8601(6) - posts = posts.first(limit) + + q = q.where('posts.url LIKE ?', "%#{ url }%") if url + q = q.where('posts.title LIKE ?', "%#{ title }%") if title + if original_created_from + q = q.where('posts.original_created_before > ?', original_created_from) end + if original_created_to + q = q.where('posts.original_created_from <= ?', original_created_to) + end + q = q.where('posts.created_at >= ?', created_between[0]) if created_between[0] + q = q.where('posts.created_at <= ?', created_between[1]) if created_between[1] + q = q.where('posts.updated_at >= ?', updated_between[0]) if updated_between[0] + q = q.where('posts.updated_at <= ?', updated_between[1]) if updated_between[1] + + sort_sql = + 'COALESCE(posts.original_created_before - INTERVAL 1 MINUTE,' + + 'posts.original_created_from,' + + 'posts.created_at)' + posts = q.select("posts.*, #{ sort_sql } AS sort_ts") + .order(Arel.sql("#{ sort_sql } DESC")) + .limit(limit).offset(offset).to_a render json: { posts: posts.map { |post| PostRepr.base(post).tap do |json| @@ -44,11 +52,7 @@ class PostsController < ApplicationController nil end end - }, count: if filtered_posts.group_values.present? - filtered_posts.count.size - else - filtered_posts.count - end, next_cursor: } + }, count: q.group_values.present? ? q.count.size : q.count } end def random @@ -63,7 +67,7 @@ class PostsController < ApplicationController end def show - post = Post.includes(tags: { tag_name: :wiki_page }).find(params[:id]) + 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 @@ -84,7 +88,7 @@ class PostsController < ApplicationController title = params[:title].presence url = params[:url] thumbnail = params[:thumbnail] - tag_names = params[:tags].to_s.split(' ') + tag_names = params[:tags].to_s.split original_created_from = params[:original_created_from] original_created_before = params[:original_created_before] @@ -125,7 +129,7 @@ class PostsController < ApplicationController return head :forbidden unless current_user.member? title = params[:title].presence - tag_names = params[:tags].to_s.split(' ') + tag_names = params[:tags].to_s.split original_created_from = params[:original_created_from] original_created_before = params[:original_created_before] @@ -192,7 +196,7 @@ class PostsController < ApplicationController private def filtered_posts - tag_names = params[:tags].to_s.split(' ') + tag_names = params[:tags].to_s.split match_type = params[:match] if tag_names.present? filter_posts_by_tags(tag_names, match_type) diff --git a/backend/spec/requests/posts_spec.rb b/backend/spec/requests/posts_spec.rb index 7ccdb52..c8621b1 100644 --- a/backend/spec/requests/posts_spec.rb +++ b/backend/spec/requests/posts_spec.rb @@ -1,7 +1,8 @@ +include ActiveSupport::Testing::TimeHelpers + require 'rails_helper' require 'set' - RSpec.describe 'Posts API', type: :request do # create / update で thumbnail.attach は走るが、 # resized_thumbnail! が MiniMagick 依存でコケやすいので request spec ではスタブしとくのが無難。 @@ -114,6 +115,204 @@ RSpec.describe 'Posts API', type: :request do expect(json.fetch('count')).to eq(0) 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 diff --git a/frontend/src/pages/posts/PostSearchPage.tsx b/frontend/src/pages/posts/PostSearchPage.tsx index a03e834..41ca095 100644 --- a/frontend/src/pages/posts/PostSearchPage.tsx +++ b/frontend/src/pages/posts/PostSearchPage.tsx @@ -78,7 +78,7 @@ export default (() => {
setURL (e.target.value)} className="w-full border p-2 rounded"/> @@ -113,6 +113,18 @@ export default (() => {
+ {/* オリジナルの投稿日時 */} +
+ + setOriginalCreatedFrom (isoUTC ?? undefined)}/> + + setOriginalCreatedTo (isoUTC ?? undefined)}/> +
+ {/* 投稿日時 */}
@@ -137,18 +149,6 @@ export default (() => { onChange={isoUTC => setUpdatedTo (isoUTC ?? undefined)}/>
- {/* オリジナルの投稿日時 */} -
- - setOriginalCreatedFrom (isoUTC ?? undefined)}/> - - setOriginalCreatedTo (isoUTC ?? undefined)}/> -
- {/* 検索 */}