コミットを比較

..
6 コミット
作成者 SHA1 メッセージ 日付
みてるぞ 57c141e515 #416 2026-09-21 07:16:14 +09:00
みてるぞ 32eb0694f6 #416 2026-09-21 07:06:09 +09:00
みてるぞ 7f0efc3a46 post_tags の論理削除と履歴管理を廃止 (#411) (#417)
Reviewed-on: #417
Co-authored-by: miteruzo <miteruzo@naver.com>
2026-09-21 06:02:41 +09:00
みてるぞ a947032247 ニジラー情報で他ニジラー・タグの持つ情報を奪へてしまふ問題 (#406) (#407)
画面確認がまだ.

Reviewed-on: #407
2026-09-20 03:23:36 +09:00
みてるぞ 3115e31fb1 post_versions.tags_json への移行 (#354) (#415)
画面テストまだ.

Reviewed-on: #415
Co-authored-by: miteruzo <miteruzo@naver.com>
2026-09-20 02:43:31 +09:00
みてるぞ f1181e8510 広場投稿追加画面の刷新 (#399) (#413)
Reviewed-on: http://git.miteruzo.com/miteruzo/btrc-hub/pulls/413
Co-authored-by: miteruzo <miteruzo@naver.com>
Co-committed-by: miteruzo <miteruzo@naver.com>
2026-07-19 00:03:10 +09:00
91個のファイルの変更2977行の追加2303行の削除
+157 -24
ファイルの表示
@@ -124,7 +124,7 @@ npm run preview
- For arrays, never put whitespace or a line break immediately before `]`. - For arrays, never put whitespace or a line break immediately before `]`.
- Keep the first element on the same line as `[` by default. - Keep the first element on the same line as `[` by default.
- If an array would exceed the line limit, break after `[` and indent - If an array would exceed the line limit, break after `[` and indent
elements by 4 spaces. elements 4 spaces deeper than the statement's base indentation.
### Ruby delimiter and wrapping rules ### Ruby delimiter and wrapping rules
@@ -140,6 +140,32 @@ npm run preview
99 文字を超えるなら block 形式へ切り替へるか、message 定数化などで縮める。 99 文字を超えるなら block 形式へ切り替へるか、message 定数化などで縮める。
- Ruby の method chain や call argument を折り返す際、call-site の `)` - Ruby の method chain や call argument を折り返す際、call-site の `)`
block close のやうに独立させない。 block close のやうに独立させない。
- Ruby では、行末の `\` を用途を問はず一切使用しない。
- Ruby では、文字列連結、logger message、method call、条件式、SQL 断片、
正規表現その他すべての式で、行末バックスラッシュによる継続を禁止する。
- Ruby の block body は、その基準位置から 2 空白深くする。
- Ruby の wrapped expression、method argument、array element、hash pair などの
continuation indentation は、その statement の基準位置から 4 空白深くする。
- Ruby の continuation indentation を、行頭からの絶対空白数として扱はない。
- Ruby では、暗黙的に継続可能な構文を優先し、method call、array、Hash 及び
括弧内ではバックスラッシュなしで改行する。
- Ruby では、行長制限を守るために行末バックスラッシュを導入してはならない。
- Ruby で行末バックスラッシュが必要に見える場合は、括弧内で自然に改行する、
一つの文字列補間へまとめる、中間変数へ分ける、`format` を使ふ、heredoc を
使ふ、array 又は Hash を組み立ててから処理する、条件式全体を括弧で囲む、
method へ抽出する、のいづれかへ書き換へる。
- Ruby では、一つの文字列を、改行をまたいだ隣接文字列 literal として記述しない。
- Ruby では、method argument 内でも、複数の文字列 literal を区切りなしで縦に
並べない。
- RSpec の `describe``context``it` 等の description が長い場合は、意味を
保ったまま一行へ収まる文言へ短縮する。
- 文字列を短縮できない場合は、用途に応じて `format`、heredoc 又は中間変数を
検討する。
- ただし RSpec description では、原則として簡潔な一行の文字列を使ふ。
- formatter 又は自動修正にも、Ruby の行末バックスラッシュを生成させない。
- 新規 code だけでなく、今回触れる Ruby code にも行末バックスラッシュを残さない。
- 例へば class body 内の array は、class body の 2 空白を基準に、更に 4 空白
深くするため、結果として行頭から 6 空白になる。
Bad: Bad:
@@ -191,6 +217,63 @@ end
Bad: Bad:
```rb
Rails.logger.info(
"post_import_metadata_fetch_failure "\
"#{ payload.to_json }")
```
Good:
```rb
payload = {
error: e.class.name,
message: e.message }
Rails.logger.info(
"post_import_metadata_fetch_failure #{ payload.to_json }")
```
Bad:
```rb
message = "first "\
"second"
```
Good:
```rb
message = format(
'%<first>s %<second>s',
first: 'first',
second: 'second')
```
Bad:
```rb
result = first_value + \
second_value
```
Bad:
```rb
it(
'returns 409 when stale changes '
'do not conflict'
) do
```
Good:
```rb
it 'returns mergeable 409 for stale non-conflicting changes' do
```
Bad:
```rb ```rb
records.each { records.each {
do_work(_1) } do_work(_1) }
@@ -225,20 +308,37 @@ records.each {
formatting as the local reference shape: component and callback bodies use formatting as the local reference shape: component and callback bodies use
2-space block indentation, single-line bodies do not gain unnecessary 2-space block indentation, single-line bodies do not gain unnecessary
braces, wrapped expressions use 4-space continuation indentation, multi- braces, wrapped expressions use 4-space continuation indentation, multi-
stage ternaries use explicit parentheses, and only complete leading runs stage ternaries use explicit parentheses, and TypeScript / TSX indentation
of 8 spaces are compressed to tabs. and alignment whitespace compress every complete run of 8 spaces to tabs.
- In TypeScript and TSX only, tabs are for leading 8-column compression only.
- A tab does not represent one indentation level. - A tab does not represent one indentation level.
- Do not replace 2-space or 4-space indentation with tabs. - In TypeScript and TSX, first determine visible indentation using 2-space
- First determine visible indentation using 2-space block indentation and block indentation and 4-space continuation indentation, then compress every
4-space continuation indentation, then compress only complete leading runs complete run of 8 spaces used for indentation or column alignment to tabs.
of 8 spaces into tabs. - In TypeScript and TSX, 8-space compression is mandatory, not optional.
- Tabs are only for leading indentation, never for spaces after non-space - In TypeScript and TSX, this applies both to leading indentation and to
text. alignment whitespace after non-space text, such as aligned inline type
- Keep residual leading 2, 4, or 6 spaces after any tab compression. columns.
- In TypeScript and TSX, keep residual 2, 4, or 6 spaces after each tab
compression.
- In TypeScript and TSX, do not treat a tab as one logical indentation level.
- In TypeScript and TSX, do not alter string literals, template-literal
contents, regular expressions, or user-facing text merely to apply tab
compression.
- Examples: 2 columns = 2 spaces, 4 columns = 4 spaces, 6 columns = 6 - Examples: 2 columns = 2 spaces, 4 columns = 4 spaces, 6 columns = 6
spaces, 8 columns = 1 tab, 10 columns = 1 tab + 2 spaces, 12 columns = 1 spaces, 8 columns = 1 tab, 10 columns = 1 tab + 2 spaces, 12 columns = 1
tab + 4 spaces, 16 columns = 2 tabs. tab + 4 spaces, 14 columns = 1 tab + 6 spaces, 16 columns = 2 tabs.
- When TypeScript or TSX code already uses column alignment, apply the same
8-space compression rule to that alignment whitespace.
- Example:
```ts
type Props = {
row: PostImportRow
displayNumber?: number
onEdit?: () => void
onRetry?: () => void
onToggleSkip?: (checked: boolean) => void }
```
- TypeScript and TSX imports may stay on one line if they remain within the - TypeScript and TSX imports may stay on one line if they remain within the
line limit; do not expand short type-only imports mechanically. line limit; do not expand short type-only imports mechanically.
- Keep runtime value imports and type imports in separate declarations. - Keep runtime value imports and type imports in separate declarations.
@@ -474,8 +574,32 @@ and layout reuse, follow `frontend/AGENTS.md`.
wording and placement before implementing it. wording and placement before implementing it.
- Do not invent replacement copy when removing unrequested wording. - Do not invent replacement copy when removing unrequested wording.
- Do not create, modify, or run tests unless the user explicitly asks for - Do not create, modify, or run tests unless the user explicitly asks for
test work. When the user asks for tests, keep working and rerun them until test work. When the user asks for tests, keep working within the permitted
they pass or the remaining failure is clearly blocked. test-file scope and rerun them until they pass or the remaining failure is
clearly blocked.
- Test-only work includes adding, updating, deleting, reorganising, or fixing
SyntaxError in tests. During test-only work, do not modify production code.
- During test-only work, do not change production constants, behaviour, API
contracts, validation, routes, authentication, permissions, UI, copy,
dependencies, limits, thresholds, defaults, migrations, schema, or
environment settings to satisfy tests.
- Do not make production code match failing tests, mock assumptions, fixtures,
snapshots, old expectations, or stale setup. This includes changing
production constants merely because a test expects a different value.
- If test work reveals a production bug, spec mismatch, or missing behaviour,
stop without modifying production code and report: the failing test or
discovered issue, the related production file, the actual behaviour, the
expected behaviour, and why a production change appears necessary.
- Modify production code for test failures only when the user explicitly asks
for that production change. Do not expand a test task into a production task
on your own authority.
- If the user explicitly asks for both production implementation and test
updates, implement production code to the confirmed specification first,
then add or update tests to verify that specification. Never roll production
behaviour back to satisfy old tests.
- If it is unclear whether the test or the production implementation is stale,
or a test cannot be corrected without changing production code, ask the user
instead of guessing.
## Backend rules ## Backend rules
@@ -598,10 +722,10 @@ and layout reuse, follow `frontend/AGENTS.md`.
beginning of a line. beginning of a line.
- The TSX-specific self-review must confirm JSX closing markers and closing - The TSX-specific self-review must confirm JSX closing markers and closing
parentheses keep the surrounding compact style. parentheses keep the surrounding compact style.
- The TypeScript/TSX self-review must confirm leading block indentation uses - The TypeScript/TSX self-review must confirm block indentation uses 2 spaces
2 spaces per level, wrapped continuations use the repository's 4-space per level, wrapped continuations use the repository's 4-space continuation
continuation alignment, and complete leading runs of 8 spaces may be alignment, and every complete run of 8 spaces used for indentation or
compressed to tabs. alignment has been compressed to tabs.
- Prefer `const` arrow functions for TypeScript/TSX component and helper declarations. - Prefer `const` arrow functions for TypeScript/TSX component and helper declarations.
- Put two blank lines before and after top-level `const` function - Put two blank lines before and after top-level `const` function
declarations, unless imports, exports, or file boundaries make that awkward. declarations, unless imports, exports, or file boundaries make that awkward.
@@ -614,9 +738,18 @@ and layout reuse, follow `frontend/AGENTS.md`.
- Indent the block body 2 spaces deeper than the keyword and opening brace. - Indent the block body 2 spaces deeper than the keyword and opening brace.
- Put the closing `}` on its own line at the same indentation as the keyword. - Put the closing `}` on its own line at the same indentation as the keyword.
- Do not write `try {`, `catch {`, or `finally {`. - Do not write `try {`, `catch {`, or `finally {`.
- In TypeScript and TSX, convert every complete leading run of 8 spaces to a - In TypeScript and TSX, convert every complete run of 8 spaces used for
tab character. indentation or alignment to a tab character.
- A leading tab is exactly equivalent to 8 leading spaces. - In TypeScript and TSX, a tab is exactly equivalent to 8 columns, whether it
appears at the beginning of a line or in alignment whitespace after
non-space text.
- In TSX, JSX nesting uses 2-space block indentation and wrapped JSX
attributes use 4-space continuation indentation; after visible columns are
determined, compress every complete run of 8 spaces in the resulting
indentation or alignment to tabs.
- In TSX, do not leave JSX subtree indentation at 8, 10, 12, 14, or 16
columns as spaces alone; convert each complete run of 8 spaces to tabs and
keep only the residual 2, 4, or 6 spaces.
- In TypeScript and TSX function declarations, including `const` arrow - In TypeScript and TSX function declarations, including `const` arrow
function declarations, classify the parameter list before placing the closing function declarations, classify the parameter list before placing the closing
`)`. `)`.
@@ -1259,9 +1392,9 @@ to `.ts` and `.tsx`:
7. JSX `>` and `/>` stay with the final prop unless nearby code proves 7. JSX `>` and `/>` stay with the final prop unless nearby code proves
otherwise. otherwise.
8. JSX closing parentheses keep the compact local style. 8. JSX closing parentheses keep the compact local style.
9. Leading block indentation uses 2 spaces per level, wrapped continuations 9. Block indentation uses 2 spaces per level, wrapped continuations use the
use the repository's 4-space continuation alignment, and complete leading repository's 4-space continuation alignment, and every complete run of 8
runs of 8 spaces may be compressed to tabs. spaces used for indentation or alignment has been compressed to tabs.
10. No line has trailing whitespace. 10. No line has trailing whitespace.
Preferred: Preferred:
+7
ファイルの表示
@@ -85,4 +85,11 @@ class ApplicationController < ActionController::API
base_errors: }, base_errors: },
status: status:
end end
def normalise_json value
return nil if value.nil?
return JSON.parse(value) if value.is_a?(String)
value
end
end end
+1 -1
ファイルの表示
@@ -237,7 +237,7 @@ class MaterialsController < ApplicationController
end end
def resolve_material_tag! tag_name_raw def resolve_material_tag! tag_name_raw
tag_name = TagName.find_undiscard_or_create_by!(name: tag_name_raw) tag_name = TagName.find_or_create_by!(name: tag_name_raw)
tag = tag_name.tag tag = tag_name.tag
tag || Tag.create!(tag_name:, category: :material) tag || Tag.create!(tag_name:, category: :material)
end end
+64 -55
ファイルの表示
@@ -1,7 +1,7 @@
class PostVersionsController < ApplicationController class PostVersionsController < ApplicationController
def index def index
post_id = params[:post].presence post_id = params[:post].presence
tag_id = params[:tag].presence tag_id = params[:tag].presence&.to_i
page = (params[:page].presence || 1).to_i page = (params[:page].presence || 1).to_i
limit = (params[:limit].presence || 20).to_i limit = (params[:limit].presence || 20).to_i
@@ -10,12 +10,6 @@ class PostVersionsController < ApplicationController
offset = (page - 1) * limit offset = (page - 1) * limit
tag_name =
if tag_id
TagName.joins(:tag).find_by(tag: { id: tag_id })
end
return render json: { versions: [], count: 0 } if tag_id && tag_name.blank?
q = PostVersion.joins(<<~SQL.squish) q = PostVersion.joins(<<~SQL.squish)
LEFT JOIN LEFT JOIN
post_versions prev post_versions prev
@@ -23,17 +17,18 @@ class PostVersionsController < ApplicationController
prev.post_id = post_versions.post_id prev.post_id = post_versions.post_id
AND prev.version_no = post_versions.version_no - 1 AND prev.version_no = post_versions.version_no - 1
SQL SQL
.select('post_versions.*', 'prev.title AS prev_title', 'prev.url AS prev_url', .select('post_versions.*',
'prev.thumbnail_base AS prev_thumbnail_base', 'prev.tags AS prev_tags', 'prev.title AS prev_title',
'prev.url AS prev_url',
'prev.thumbnail_base AS prev_thumbnail_base',
'prev.tags_json AS prev_tags_json',
'prev.video_ms AS prev_video_ms', 'prev.video_ms AS prev_video_ms',
'prev.original_created_from AS prev_original_created_from', 'prev.original_created_from AS prev_original_created_from',
'prev.original_created_before AS prev_original_created_before') 'prev.original_created_before AS prev_original_created_before')
q = q.where('post_versions.post_id = ?', post_id) if post_id q = q.where('post_versions.post_id = ?', post_id) if post_id
if tag_name if tag_id
escaped = ActiveRecord::Base.sanitize_sql_like(tag_name.name) q = q.where("JSON_CONTAINS(post_versions.tags_json, JSON_OBJECT('id', #{ tag_id })) " +
q = q.where(("CONCAT(' ', post_versions.tags, ' ') LIKE :kw " + "OR JSON_CONTAINS(prev.tags_json, JSON_OBJECT('id', #{ tag_id }))")
"OR CONCAT(' ', prev.tags, ' ') LIKE :kw"),
kw: "% #{ escaped } %")
end end
count = q.except(:select, :order, :limit, :offset).count count = q.except(:select, :order, :limit, :offset).count
@@ -52,51 +47,69 @@ class PostVersionsController < ApplicationController
users_by_id = User.where(id: user_ids).pluck(:id, :name).to_h users_by_id = User.where(id: user_ids).pluck(:id, :name).to_h
rows.map do |row| rows.map do |row|
cur_tags = split_tags(row.tags) cur_tags =
prev_tags = split_tags(row.attributes['prev_tags']) normalise_json(row.tags_json)
.sort_by { [(case _1.fetch('category')
when 'deerjikist'
0
when 'meme'
1
when 'character'
2
when 'general'
3
when 'material'
4
when 'meta'
5
else
6
end),
_1.fetch('name').downcase] }
.map { Post.tag_snapshot_literal(_1) }
prev_tags =
(normalise_json(row.attributes['prev_tags_json']) || [])
.sort_by { [(case _1.fetch('category')
when 'deerjikist'
0
when 'meme'
1
when 'character'
2
when 'general'
3
when 'material'
4
when 'meta'
5
else
6
end),
_1.fetch('name').downcase] }
.map { Post.tag_snapshot_literal(_1) }
{ { post_id: row.post_id,
post_id: row.post_id,
version_no: row.version_no, version_no: row.version_no,
event_type: row.event_type, event_type: row.event_type,
title: { title: { current: row.title, prev: row.attributes['prev_title'] },
current: row.title, url: { current: row.url, prev: row.attributes['prev_url'] },
prev: row.attributes['prev_title'] thumbnail: { current: nil, prev: nil },
}, thumbnail_base: { current: row.thumbnail_base,
url: { prev: row.attributes['prev_thumbnail_base'] },
current: row.url, video_ms: { current: row.video_ms, prev: row.attributes['prev_video_ms'] },
prev: row.attributes['prev_url']
},
thumbnail: {
current: nil,
prev: nil
},
thumbnail_base: {
current: row.thumbnail_base,
prev: row.attributes['prev_thumbnail_base']
},
video_ms: {
current: row.video_ms,
prev: row.attributes['prev_video_ms']
},
tags: build_version_tags(cur_tags, prev_tags), tags: build_version_tags(cur_tags, prev_tags),
original_created_from: { original_created_from: {
current: row.original_created_from&.iso8601, current: row.original_created_from&.iso8601,
prev: row.attributes['prev_original_created_from']&.iso8601 prev: row.attributes['prev_original_created_from']&.iso8601 },
},
original_created_before: { original_created_before: {
current: row.original_created_before&.iso8601, current: row.original_created_before&.iso8601,
prev: row.attributes['prev_original_created_before']&.iso8601 prev: row.attributes['prev_original_created_before']&.iso8601 },
},
created_at: row.created_at.iso8601, created_at: row.created_at.iso8601,
created_by_user: created_by_user:
if row.created_by_user_id if row.created_by_user_id
{ { id: row.created_by_user_id,
id: row.created_by_user_id, name: users_by_id[row.created_by_user_id] }
name: users_by_id[row.created_by_user_id] end }
}
end
}
end end
end end
@@ -117,8 +130,4 @@ class PostVersionsController < ApplicationController
} }
end end
end end
def split_tags(tags)
tags.to_s.split(/\s+/).reject(&:blank?)
end
end end
+41 -63
ファイルの表示
@@ -36,8 +36,8 @@ class PostsController < ApplicationController
offset = (page - 1) * limit offset = (page - 1) * limit
pt_max_sql = pt_max_sql =
PostTag PostVersion
.select('post_id, MAX(updated_at) AS max_updated_at') .select('post_id, MAX(created_at) AS max_updated_at')
.group('post_id') .group('post_id')
.to_sql .to_sql
@@ -50,9 +50,8 @@ class PostsController < ApplicationController
.joins("LEFT JOIN (#{ pt_max_sql }) pt_max ON pt_max.post_id = posts.id") .joins("LEFT JOIN (#{ pt_max_sql }) pt_max ON pt_max.post_id = posts.id")
.reselect('posts.*', Arel.sql("#{ updated_at_all_sql } AS updated_at_all")) .reselect('posts.*', Arel.sql("#{ updated_at_all_sql } AS updated_at_all"))
.preload(:uploaded_user, :parents, :children, .preload(:uploaded_user, :parents, :children,
active_post_tags: [:sections, post_tags: [:sections, { tag: [:deerjikists, :materials,
{ tag: [:deerjikists, :materials, { tag_name: :wiki_page }] }])
{ tag_name: :wiki_page }] }])
.with_attached_thumbnail .with_attached_thumbnail
q = q.where('posts.url LIKE ?', "%#{ url }%") if url q = q.where('posts.url LIKE ?', "%#{ url }%") if url
@@ -104,9 +103,8 @@ class PostsController < ApplicationController
def random def random
post = filtered_posts.preload(:uploaded_user, :parents, :children, post = filtered_posts.preload(:uploaded_user, :parents, :children,
active_post_tags: [:sections, post_tags: [:sections, { tag: [:deerjikists, :materials,
{ tag: [:deerjikists, :materials, { tag_name: :wiki_page }] }])
{ tag_name: :wiki_page }] }])
.with_attached_thumbnail .with_attached_thumbnail
.order('RAND()') .order('RAND()')
.first .first
@@ -190,9 +188,8 @@ class PostsController < ApplicationController
post = post =
Post Post
.includes(:uploaded_user, :parents, :children, .includes(:uploaded_user, :parents, :children,
active_post_tags: [:sections, post_tags: [:sections, { tag: [:deerjikists, :materials,
{ tag: [:deerjikists, :materials, { tag_name: :wiki_page }] }])
{ tag_name: :wiki_page }] }])
.with_attached_thumbnail .with_attached_thumbnail
.find_by(id: params[:id]) .find_by(id: params[:id])
return head :not_found unless post return head :not_found unless post
@@ -216,7 +213,8 @@ class PostsController < ApplicationController
preflight = PostCreatePreflight.new( preflight = PostCreatePreflight.new(
attributes: post_create_attributes, attributes: post_create_attributes,
thumbnail: params[:thumbnail]).run thumbnail: params[:thumbnail],
host: request.base_url).run
return render json: dry_run_json(preflight) if bool?(:dry) return render json: dry_run_json(preflight) if bool?(:dry)
if preflight[:existing_post_id].present? if preflight[:existing_post_id].present?
post = Post.new(url: preflight[:url]) post = Post.new(url: preflight[:url])
@@ -273,7 +271,11 @@ class PostsController < ApplicationController
return head :payload_too_large if request.content_length.to_i > MAX_BULK_REQUEST_BYTES return head :payload_too_large if request.content_length.to_i > MAX_BULK_REQUEST_BYTES
posts = parse_bulk_posts_manifest posts = parse_bulk_posts_manifest
thumbnails = parse_bulk_thumbnails(posts.length) thumbnails = parse_bulk_thumbnails(posts.length)
result = PostBulkCreator.new(actor: current_user, posts:, thumbnails:).run result = PostBulkCreator.new(
actor: current_user,
posts:,
thumbnails:,
host: request.base_url).run
render json: result render json: result
rescue JSON::ParserError rescue JSON::ParserError
render_bad_request 'posts manifest の JSON が不正です.' render_bad_request 'posts manifest の JSON が不正です.'
@@ -379,50 +381,6 @@ class PostsController < ApplicationController
render_post_form_record_invalid e.record render_post_form_record_invalid e.record
end end
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
page = 1 if page < 1
limit = 1 if limit < 1
offset = (page - 1) * limit
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: [:deerjikists, :materials, { tag_name: :wiki_page }])
events = []
pts.each do |pt|
tag = TagRepr.base(pt.tag)
post = pt.post
events << Event.new(
post:,
tag:,
user: pt.created_user && { id: pt.created_user.id, name: pt.created_user.name },
change_type: 'add',
timestamp: pt.created_at)
if pt.discarded_at
events << Event.new(
post:,
tag:,
user: pt.deleted_user && { id: pt.deleted_user.id, name: pt.deleted_user.name },
change_type: 'remove',
timestamp: pt.discarded_at)
end
end
events.sort_by!(&:timestamp)
events.reverse!
render json: { changes: (events.slice(offset, limit) || []).as_json, count: events.size }
end
private private
def filtered_posts def filtered_posts
@@ -497,13 +455,13 @@ class PostsController < ApplicationController
end end
end end
PostTag.where(post_id: post.id, tag_id: to_remove.to_a).kept.find_each do |pt| PostTag.where(post_id: post.id, tag_id: to_remove.to_a).find_each do |pt|
pt.discard_by!(current_user) pt.destroy!
end end
end end
def build_tag_tree_for post def build_tag_tree_for post
post_tags = post.active_post_tags.reject { |post_tag| post_tag.tag.deprecated? } post_tags = post.post_tags.reject { |post_tag| post_tag.tag.deprecated? }
tags = post_tags.map(&:tag) tags = post_tags.map(&:tag)
tag_ids = tags.map(&:id) tag_ids = tags.map(&:id)
@@ -627,7 +585,7 @@ class PostsController < ApplicationController
return nil if post_id.blank? return nil if post_id.blank?
post = Post.with_attached_thumbnail.find_by(id: post_id) post = Post.with_attached_thumbnail.find_by(id: post_id)
PostCompactRepr.base(post) PostCompactRepr.base(post, host: request.base_url)
end end
def dry_run_json preflight def dry_run_json preflight
@@ -694,7 +652,10 @@ class PostsController < ApplicationController
end end
def editable_tag_names_from_version version def editable_tag_names_from_version version
version.tags.to_s.split.reject { |name| name.downcase.start_with?('nico:') }.sort version.tags_json
.reject { _1.fetch('category') == 'nico' }
.map { Post.tag_snapshot_literal(_1) }
.sort
end end
def post_snapshot_from_record post def post_snapshot_from_record post
@@ -709,7 +670,6 @@ class PostsController < ApplicationController
def editable_tag_names_from_post post def editable_tag_names_from_post post
post post
.post_tags .post_tags
.kept
.joins(tag: :tag_name) .joins(tag: :tag_name)
.merge(Tag.not_nico) .merge(Tag.not_nico)
.merge(Tag.where(deprecated_at: nil)) .merge(Tag.where(deprecated_at: nil))
@@ -727,6 +687,7 @@ class PostsController < ApplicationController
def post_incoming_snapshot title:, original_created_from:, original_created_before:, def post_incoming_snapshot title:, original_created_from:, original_created_before:,
tag_names:, video_ms_param:, duration_param:, parent_post_ids: tag_names:, video_ms_param:, duration_param:, parent_post_ids:
validate_original_created_values!(original_created_from, original_created_before)
Tag.normalise_tags!(tag_names, with_tagme: false, deny_deprecated: true, Tag.normalise_tags!(tag_names, with_tagme: false, deny_deprecated: true,
with_sections: true) => with_sections: true) =>
{ tags:, sections: } { tags:, sections: }
@@ -764,6 +725,23 @@ class PostsController < ApplicationController
value.to_s value.to_s
end end
def validate_original_created_values! original_created_from, original_created_before
candidate = Post.new(
url: 'https://example.invalid/original-created-validation',
original_created_from:,
original_created_before:)
candidate.valid?
fields = [:original_created_from, :original_created_before, :original_created_at]
relevant_errors = candidate.errors.select { fields.include?(_1.attribute) }
return if relevant_errors.empty?
invalid_post = Post.new
relevant_errors.each { |error|
invalid_post.errors.add(error.attribute, error.message)
}
raise ActiveRecord::RecordInvalid, invalid_post
end
def section_literal section def section_literal section
"[#{ Post.ms_to_time(section[0]) }-#{ section[1] ? Post.ms_to_time(section[1]) : '' }]" "[#{ Post.ms_to_time(section[0]) }-#{ section[1] ? Post.ms_to_time(section[1]) : '' }]"
end end
+121 -18
ファイルの表示
@@ -200,14 +200,45 @@ class TagsController < ApplicationController
.find_by(id: params[:id]) .find_by(id: params[:id])
return head :not_found unless tag return head :not_found unless tag
rows = normalise_deerjikist_rows(tag)
return if performed?
ApplicationRecord.transaction do ApplicationRecord.transaction do
tag.deerjikists = [] tag.lock!
params[:_json].each.with_index do |item, i|
platform = item[:platform] requested_keys = rows.map { |row| [row[:platform], row[:code]] }.uniq
code = normalise_deerjikist_code(platform, item[:code]) row_indexes_by_key = rows_by_key(rows)
deerjikist = Deerjikist.find_or_initialize_by(platform:, code:) locked_deerjikists = lock_deerjikists_for_tag_update(tag.id, requested_keys)
deerjikist.tag = tag current_deerjikists = locked_deerjikists.filter { |deerjikist|
render_deerjikist_form_record_invalid(deerjikist, i) unless deerjikist.save deerjikist.tag_id == tag.id
}
requested_deerjikists = locked_deerjikists.filter { |deerjikist|
row_indexes_by_key.key?([deerjikist.platform, deerjikist.code])
}
render_deerjikist_conflicts(requested_deerjikists, row_indexes_by_key, tag)
raise ActiveRecord::Rollback if performed?
requested_keys_set = requested_keys.to_set
current_deerjikists.each do |deerjikist|
key = [deerjikist.platform, deerjikist.code]
deerjikist.destroy! unless requested_keys_set.include?(key)
end
existing_keys = requested_deerjikists.to_h { |deerjikist|
[[deerjikist.platform, deerjikist.code], true]
}
requested_keys.each do |platform, code|
next if existing_keys[[platform, code]]
deerjikist = Deerjikist.new(platform:, code:, tag:)
row_index = row_indexes_by_key[[platform, code]].first
begin
render_deerjikist_form_record_invalid(deerjikist, row_index) unless deerjikist.save
rescue ActiveRecord::RecordNotUnique
conflicts = lock_deerjikists_for_tag_update(tag.id, [[platform, code]])
render_deerjikist_conflicts(conflicts, row_indexes_by_key, tag)
end
raise ActiveRecord::Rollback if performed? raise ActiveRecord::Rollback if performed?
end end
end end
@@ -542,7 +573,7 @@ class TagsController < ApplicationController
return false return false
end end
target_tag_name = TagName.with_discarded.find_by(name:) target_tag_name = TagName.find_by(name:)
return true if target_tag_name.nil? return true if target_tag_name.nil?
return true if target_tag_name.canonical_id? return true if target_tag_name.canonical_id?
@@ -554,17 +585,14 @@ class TagsController < ApplicationController
return if name == tag.name return if name == tag.name
current_tag_name = tag.tag_name current_tag_name = tag.tag_name
target_tag_name = TagName.with_discarded.find_by(name:) target_tag_name = TagName.find_by(name:)
if target_tag_name.nil? if target_tag_name.nil?
current_tag_name.update!(name:) current_tag_name.update!(name:)
return return
end end
promote_tag_alias!( promote_tag_alias!(tag, current_tag_name:, promoted_tag_name: target_tag_name)
tag,
current_tag_name:,
promoted_tag_name: target_tag_name)
end end
def promote_tag_alias! tag, current_tag_name:, promoted_tag_name: def promote_tag_alias! tag, current_tag_name:, promoted_tag_name:
@@ -574,11 +602,9 @@ class TagsController < ApplicationController
TagVersioning.ensure_snapshot!(old_owner_tag, created_by_user: current_user) TagVersioning.ensure_snapshot!(old_owner_tag, created_by_user: current_user)
end end
promoted_tag_name.undiscard! if promoted_tag_name.discarded?
promoted_tag_name.update!(canonical: nil) promoted_tag_name.update!(canonical: nil)
TagName.with_discarded TagName.where(canonical_id: current_tag_name.id)
.where(canonical_id: current_tag_name.id)
.where.not(id: promoted_tag_name.id) .where.not(id: promoted_tag_name.id)
.find_each do |alias_tag_name| .find_each do |alias_tag_name|
alias_tag_name.update!(canonical: promoted_tag_name) alias_tag_name.update!(canonical: promoted_tag_name)
@@ -609,7 +635,7 @@ class TagsController < ApplicationController
end end
alias_names.each do |alias_name| alias_names.each do |alias_name|
alias_tag_name = TagName.find_undiscard_or_create_by!(name: alias_name) alias_tag_name = TagName.find_or_create_by!(name: alias_name)
affected_tags << alias_tag_name.canonical&.tag affected_tags << alias_tag_name.canonical&.tag
end end
@@ -624,7 +650,7 @@ class TagsController < ApplicationController
end end
alias_names.each do |alias_name| alias_names.each do |alias_name|
alias_tag_name = TagName.find_undiscard_or_create_by!(name: alias_name) alias_tag_name = TagName.find_or_create_by!(name: alias_name)
alias_tag_name.update!(canonical: tag.tag_name) alias_tag_name.update!(canonical: tag.tag_name)
end end
@@ -653,6 +679,7 @@ class TagsController < ApplicationController
end end
def normalise_deerjikist_code platform, code def normalise_deerjikist_code platform, code
code = code.to_s
return code if platform != 'youtube' || code[0] != '@' return code if platform != 'youtube' || code[0] != '@'
url = "https://www.youtube.com/#{ code }" url = "https://www.youtube.com/#{ code }"
@@ -669,6 +696,82 @@ class TagsController < ApplicationController
nil nil
end end
def normalise_deerjikist_rows tag
rows = []
params[:_json].each.with_index do |item, index|
platform = item[:platform]
unless Deerjikist.platforms.key?(platform)
render_deerjikist_platform_invalid(index)
return rows
end
code = normalise_deerjikist_code(platform, item[:code])
deerjikist = Deerjikist.new(platform:, code:, tag:)
unless deerjikist.valid?
render_deerjikist_form_record_invalid(deerjikist, index)
return rows
end
rows << { index:, platform:, code: }
end
rows
end
def lock_deerjikists_for_tag_update tag_id, keys
clauses = ['tag_id = ?']
values = [tag_id]
keys.each do |platform, code|
clauses << '(platform = ? AND code = ?)'
values << platform << code
end
Deerjikist
.where(clauses.join(' OR '), *values)
.order(:platform, :code)
.lock
.to_a
end
def render_deerjikist_conflicts deerjikists, row_indexes_by_key, tag
conflicts = deerjikists.filter { |deerjikist| deerjikist.tag_id != tag.id }
return if conflicts.empty?
tag_names_by_id = Tag
.joins(:tag_name)
.where(id: conflicts.map(&:tag_id).uniq)
.pluck('tags.id', 'tag_names.name')
.to_h
fields = { }
conflicts.each do |deerjikist|
message = "この情報は既に「#{ tag_names_by_id[deerjikist.tag_id] }」に紐づいてゐます."
row_indexes_by_key[[deerjikist.platform, deerjikist.code]].each do |index|
field = :"deerjikists.#{ index }.code"
fields[field] ||= []
fields[field] << message
end
end
render_validation_error fields:
end
def render_deerjikist_platform_invalid index
render_validation_error fields: {
:"deerjikists.#{ index }.platform" => ['値が不正です.'],
}
end
def rows_by_key rows
rows.each_with_object({ }) do |row, result|
key = [row[:platform], row[:code]]
result[key] ||= []
result[key] << row[:index]
end
end
def render_deerjikist_form_record_invalid deerjikist, index def render_deerjikist_form_record_invalid deerjikist, index
fields = { } fields = { }
+1 -1
ファイルの表示
@@ -94,7 +94,7 @@ class WikiPagesController < ApplicationController
return render_unprocessable_entity('タイトルは必須です.', field: :title) if title.blank? return render_unprocessable_entity('タイトルは必須です.', field: :title) if title.blank?
return render_unprocessable_entity('本文は必須です.', field: :body) if body.blank? return render_unprocessable_entity('本文は必須です.', field: :body) if body.blank?
tag_name = TagName.find_undiscard_or_create_by!(name: title) tag_name = TagName.find_or_create_by!(name: title)
page = page =
Wiki::Commit.create_content!( Wiki::Commit.create_content!(
+52 -9
ファイルの表示
@@ -55,25 +55,27 @@ class Post < ApplicationRecord
belongs_to :uploaded_user, class_name: 'User', optional: true belongs_to :uploaded_user, class_name: 'User', optional: true
has_many :post_tags, dependent: :destroy, inverse_of: :post has_many :post_tags, dependent: :destroy, inverse_of: :post
has_many :active_post_tags, -> { kept }, class_name: 'PostTag', inverse_of: :post has_many :tags, through: :post_tags
has_many :post_tags_with_discarded, -> { with_discarded }, class_name: 'PostTag'
has_many :tags, through: :active_post_tags
has_many :active_tags, -> { where(tags: { deprecated_at: nil }) }, has_many :active_tags, -> { where(tags: { deprecated_at: nil }) },
through: :active_post_tags, source: :tag through: :post_tags,
source: :tag
has_many :user_post_views, dependent: :delete_all has_many :user_post_views, dependent: :delete_all
has_many :post_similarities, dependent: :delete_all has_many :post_similarities, dependent: :delete_all
has_many :post_versions has_many :post_versions
has_many :gekanator_guessed_games, has_many :gekanator_guessed_games,
class_name: 'GekanatorGame', class_name: 'GekanatorGame',
foreign_key: :guessed_post_id, foreign_key: :guessed_post_id,
dependent: :delete_all, dependent: :delete_all,
inverse_of: :guessed_post inverse_of: :guessed_post
has_many :gekanator_correct_games, has_many :gekanator_correct_games,
class_name: 'GekanatorGame', class_name: 'GekanatorGame',
foreign_key: :correct_post_id, foreign_key: :correct_post_id,
dependent: :delete_all, dependent: :delete_all,
inverse_of: :correct_post inverse_of: :correct_post
has_many :gekanator_question_examples, dependent: :delete_all has_many :gekanator_question_examples, dependent: :delete_all
has_many :parent_post_implications, has_many :parent_post_implications,
@@ -123,7 +125,6 @@ class Post < ApplicationRecord
def snapshot_tag_names def snapshot_tag_names
post_tags post_tags
.kept
.joins(tag: :tag_name) .joins(tag: :tag_name)
.includes(:sections, tag: :tag_name) .includes(:sections, tag: :tag_name)
.order('tag_names.name') .order('tag_names.name')
@@ -137,8 +138,38 @@ class Post < ApplicationRecord
end end
end end
def self.tag_snapshot_literal tag
sections = tag.fetch('sections', []).map do |sec|
begin_ms = sec.fetch('begin_ms')
end_ms = sec['end_ms']
"[#{ Post.ms_to_time(begin_ms) }-#{ end_ms ? Post.ms_to_time(end_ms) : '' }]"
end
"#{ tag.fetch('name') }#{ sections.join }"
end
def snapshot_tags_json
post_tags
.joins(tag: :tag_name)
.includes(:sections, tag: :tag_name)
.order('tags.id')
.map do |pt|
{ 'id' => pt.tag.id,
'version_no' => pt.tag.version_no,
'name' => pt.tag.name,
'category' => pt.tag.category,
'sections' => pt.sections.sort_by(&:begin_ms).map {
{ 'begin_ms' => _1.begin_ms, 'end_ms' => _1.end_ms }
} }
end
end
def self.section_literal section def self.section_literal section
"[#{ Post.ms_to_time(section.begin_ms) }-#{ section.end_ms ? Post.ms_to_time(section.end_ms) : '' }]" end_ms =
section.end_ms ? Post.ms_to_time(section.end_ms) : ''
"[#{ Post.ms_to_time(section.begin_ms) }-#{ end_ms }]"
end end
def self.ms_to_time ms def self.ms_to_time ms
@@ -323,7 +354,9 @@ class Post < ApplicationRecord
end end
def self.external_svg_reference?(attribute_name, attribute_value) def self.external_svg_reference?(attribute_name, attribute_value)
return external_svg_url?(attribute_value) if ['href', 'xlink:href', 'src'].include?(attribute_name) if ['href', 'xlink:href', 'src'].include?(attribute_name)
return external_svg_url?(attribute_value)
end
return style_contains_disallowed_urls?(attribute_value) if attribute_name == 'style' return style_contains_disallowed_urls?(attribute_value) if attribute_name == 'style'
return svg_url_function_disallowed?(attribute_value) if attribute_value.match?(/url\s*\(/i) return svg_url_function_disallowed?(attribute_value) if attribute_value.match?(/url\s*\(/i)
@@ -344,7 +377,12 @@ class Post < ApplicationRecord
def self.svg_url_function_disallowed?(value) def self.svg_url_function_disallowed?(value)
value.to_s.scan(/url\s*\(([^)]*)\)/i).flatten.any? do |entry| value.to_s.scan(/url\s*\(([^)]*)\)/i).flatten.any? do |entry|
reference = entry.to_s.strip.delete_prefix("'").delete_prefix('"').delete_suffix("'").delete_suffix('"') reference =
entry.to_s.strip
.delete_prefix("'")
.delete_prefix('"')
.delete_suffix("'")
.delete_suffix('"')
reference.present? && !(reference.start_with?('#')) reference.present? && !(reference.start_with?('#'))
end end
end end
@@ -433,7 +471,12 @@ class Post < ApplicationRecord
match = match =
value.match( value.match(
/\A(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2})(?:\.(\d+))?)?(Z|[+-]\d{2}:?\d{2})?\z/) /
\A(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})
(?::(\d{2})(?:\.(\d+))?)?
(Z|[+-]\d{2}:?\d{2})?
\z
/x)
return nil if match.nil? return nil if match.nil?
year = match[1].to_i year = match[1].to_i
+1 -21
ファイルの表示
@@ -1,14 +1,7 @@
class PostTag < ApplicationRecord class PostTag < ApplicationRecord
include Discard::Model
before_destroy do
raise ActiveRecord::ReadOnlyRecord, '消さないでください.'
end
belongs_to :post belongs_to :post
belongs_to :tag, counter_cache: :post_count belongs_to :tag, counter_cache: :post_count
belongs_to :created_user, class_name: 'User', optional: true belongs_to :created_user, class_name: 'User', optional: true
belongs_to :deleted_user, class_name: 'User', optional: true
has_many :sections, -> { order(:begin_ms) }, class_name: 'PostTagSection', has_many :sections, -> { order(:begin_ms) }, class_name: 'PostTagSection',
foreign_key: [:post_id, :tag_id], foreign_key: [:post_id, :tag_id],
@@ -18,18 +11,5 @@ class PostTag < ApplicationRecord
validates :post_id, presence: true validates :post_id, presence: true
validates :tag_id, presence: true validates :tag_id, presence: true
validates :post_id, uniqueness: { validates :post_id, uniqueness: { scope: :tag_id }
scope: :tag_id,
conditions: -> { where(discarded_at: nil) } }
def discard_by! deleted_user
return self if discarded?
transaction do
update!(discarded_at: Time.current, deleted_user:)
Tag.where(id: tag_id).update_all('post_count = GREATEST(post_count - 1, 0)')
end
self
end
end end
+4 -4
ファイルの表示
@@ -4,10 +4,10 @@ class PostTagSection < ApplicationRecord
belongs_to :post belongs_to :post
belongs_to :tag belongs_to :tag
belongs_to :post_tag, -> { kept }, foreign_key: [:post_id, :tag_id], belongs_to :post_tag, foreign_key: [:post_id, :tag_id],
primary_key: [:post_id, :tag_id], primary_key: [:post_id, :tag_id],
inverse_of: :sections, inverse_of: :sections,
optional: true optional: true
validates :post_id, presence: true validates :post_id, presence: true
validates :tag_id, presence: true validates :tag_id, presence: true
+10 -14
ファイルの表示
@@ -2,8 +2,6 @@ require 'set'
class Tag < ApplicationRecord class Tag < ApplicationRecord
include MyDiscard
class NicoTagNormalisationError < ArgumentError class NicoTagNormalisationError < ArgumentError
; ;
end end
@@ -28,9 +26,7 @@ class Tag < ApplicationRecord
end end
has_many :post_tags, inverse_of: :tag has_many :post_tags, inverse_of: :tag
has_many :active_post_tags, -> { kept }, class_name: 'PostTag', inverse_of: :tag has_many :posts, through: :post_tags
has_many :post_tags_with_discarded, -> { with_discarded }, class_name: 'PostTag'
has_many :posts, through: :active_post_tags
has_many :nico_tag_relations, foreign_key: :nico_tag_id, dependent: :destroy has_many :nico_tag_relations, foreign_key: :nico_tag_id, dependent: :destroy
has_many :linked_tags, through: :nico_tag_relations, source: :tag has_many :linked_tags, through: :nico_tag_relations, source: :tag
@@ -234,10 +230,10 @@ class Tag < ApplicationRecord
end end
def self.find_or_create_by_tag_name! name, category: def self.find_or_create_by_tag_name! name, category:
tn = TagName.find_undiscard_or_create_by!(name: name.to_s.strip) tn = TagName.find_or_create_by!(name: name.to_s.strip)
tn = tn.canonical if tn.canonical_id? tn = tn.canonical if tn.canonical_id?
Tag.find_undiscard_or_create_by!(tag_name_id: tn.id) do |t| Tag.find_or_create_by!(tag_name_id: tn.id) do |t|
t.category = category t.category = category
end end
rescue ActiveRecord::RecordNotUnique rescue ActiveRecord::RecordNotUnique
@@ -259,11 +255,11 @@ class Tag < ApplicationRecord
TagVersioning.ensure_snapshot!(source_tag, created_by_user:) TagVersioning.ensure_snapshot!(source_tag, created_by_user:)
source_tag.post_tags.kept.find_each do |source_pt| source_tag.post_tags.find_each do |source_pt|
post_id = source_pt.post_id post_id = source_pt.post_id
affected_post_ids << post_id affected_post_ids << post_id
source_pt.discard_by!(created_by_user) source_pt.destroy!
unless PostTag.kept.exists?(post_id:, tag: target_tag) unless PostTag.exists?(post_id:, tag: target_tag)
PostTag.create!(post_id:, tag: target_tag) PostTag.create!(post_id:, tag: target_tag)
end end
end end
@@ -275,10 +271,10 @@ class Tag < ApplicationRecord
end end
TagVersioning.record!(source_tag, event_type: :discard, created_by_user:) TagVersioning.record!(source_tag, event_type: :discard, created_by_user:)
source_tag.discard! source_tag.destroy!
if source_tag.nico? if source_tag.nico?
source_tag_name.discard! source_tag_name.destroy!
else else
source_tag_name.update_columns(canonical_id: target_tag.tag_name_id, source_tag_name.update_columns(canonical_id: target_tag.tag_name_id,
updated_at: Time.current) updated_at: Time.current)
@@ -293,13 +289,13 @@ class Tag < ApplicationRecord
end end
# 投稿件数を再集計 # 投稿件数を再集計
target_tag.update_columns(post_count: PostTag.kept.where(tag: target_tag).count) target_tag.update_columns(post_count: PostTag.where(tag: target_tag).count)
end end
target_tag.reload target_tag.reload
end end
def snapshot_aliases = tag_name.aliases.kept.order(:name).pluck(:name) def snapshot_aliases = tag_name.aliases.order(:name).pluck(:name)
def snapshot_parent_tag_ids = parents.order(:id).pluck(:id) def snapshot_parent_tag_ids = parents.order(:id).pluck(:id)
-2
ファイルの表示
@@ -1,6 +1,4 @@
class TagName < ApplicationRecord class TagName < ApplicationRecord
include MyDiscard
has_one :tag has_one :tag
has_one :wiki_page has_one :wiki_page
+1 -1
ファイルの表示
@@ -32,7 +32,7 @@ class TagNameSanitisationRule < ApplicationRecord
elsif source_tag elsif source_tag
source_tag.update_columns(tag_name_id: existing_tn.id, updated_at: Time.current) source_tag.update_columns(tag_name_id: existing_tn.id, updated_at: Time.current)
end end
tn.discard! tn.destroy!
next next
end end
+2 -2
ファイルの表示
@@ -4,11 +4,11 @@
module PostCompactRepr module PostCompactRepr
module_function module_function
def base post def base post, host: nil
return nil if post.nil? return nil if post.nil?
PostRepr PostRepr
.common(post) .common(post, host:)
.slice( .slice(
'id', 'id',
'title', 'title',
+67 -28
ファイルの表示
@@ -17,8 +17,13 @@ module PostRepr
module_function module_function
def base post, current_user = nil def base post, current_user = nil, host: nil
json = common(post) json =
if host.present?
common(post, host:)
else
common(post)
end
json['tags'] = tag_json(post) json['tags'] = tag_json(post)
json['uploaded_user'] = post.uploaded_user && UserRepr.base(post.uploaded_user) json['uploaded_user'] = post.uploaded_user && UserRepr.base(post.uploaded_user)
json['viewed'] = current_user ? current_user.viewed?(post) : false json['viewed'] = current_user ? current_user.viewed?(post) : false
@@ -26,55 +31,89 @@ module PostRepr
end end
def detail post, current_user = nil, parent_posts: [], child_posts: [], def detail post, current_user = nil, parent_posts: [], child_posts: [],
sibling_posts: { }, related: [] sibling_posts: { }, related: [], host: nil
base(post, current_user).merge( if host.present?
'parent_posts' => cards(parent_posts), base(post, current_user, host:).merge(
'child_posts' => cards(child_posts), 'parent_posts' => cards(parent_posts, host:),
'sibling_posts' => sibling_posts.transform_keys(&:to_s).transform_values { |posts| 'child_posts' => cards(child_posts, host:),
cards(posts) 'sibling_posts' => sibling_posts.transform_keys(&:to_s).transform_values { |posts|
}, cards(posts, host:)
'related' => cards(related)) },
'related' => cards(related, host:))
else
base(post, current_user).merge(
'parent_posts' => cards(parent_posts),
'child_posts' => cards(child_posts),
'sibling_posts' => sibling_posts.transform_keys(&:to_s).transform_values { |posts|
cards(posts)
},
'related' => cards(related))
end
end end
def card post def card post, host: nil
common(post).merge('parent_posts' => [], 'child_posts' => []) if host.present?
common(post, host:).merge('parent_posts' => [], 'child_posts' => [])
else
common(post).merge('parent_posts' => [], 'child_posts' => [])
end
end end
def cards posts def cards posts, host: nil
posts.map { |post| card(post) } if host.present?
posts.map { |post| card(post, host:) }
else
posts.map { |post| card(post) }
end
end end
def many posts, current_user = nil def many posts, current_user = nil, host: nil
posts.map { |p| base(p, current_user) } if host.present?
posts.map { |p| base(p, current_user, host:) }
else
posts.map { |p| base(p, current_user) }
end
end end
def common post def common post, host: nil
BASE_FIELDS.to_h { |field| [field.to_s, post.public_send(field)] } BASE_FIELDS.to_h { |field| [field.to_s, post.public_send(field)] }
.merge('thumbnail' => thumbnail_url(post)) .merge(
'thumbnail' =>
if host.present?
thumbnail_url(post, host:)
else
thumbnail_url(post)
end)
end end
def tag_json post def tag_json post
post post
.active_post_tags .post_tags
.reject { _1.tag.deprecated? } .reject { _1.tag.deprecated? }
.sort_by { _1.tag.name } .sort_by { _1.tag.name }
.map { |post_tag| .map do |post_tag|
TagRepr.inline(post_tag.tag).merge( TagRepr.inline(post_tag.tag).merge(
'children' => [], 'children' => [],
'sections' => post_tag.sections.as_json(only: [:begin_ms, :end_ms])) 'sections' => post_tag.sections.as_json(only: [:begin_ms, :end_ms]))
} end
end end
def thumbnail_url post def thumbnail_url post, host: nil
return nil unless post.thumbnail.attached? return nil unless post.thumbnail.attached?
Rails.application.routes.url_helpers.rails_blob_url(post.thumbnail, only_path: false) options = { only_path: false }
options[:host] = host if host.present?
Rails.application.routes.url_helpers.rails_storage_proxy_url(post.thumbnail, **options)
rescue ActionController::UrlGenerationError, ArgumentError, URI::InvalidURIError => e rescue ActionController::UrlGenerationError, ArgumentError, URI::InvalidURIError => e
Rails.logger.warn( payload = {
"PostRepr.thumbnail_url failed post_id=#{post.id} " \ post_id: post.id,
"attachment_id=#{post.thumbnail.attachment&.id} " \ attachment_id: post.thumbnail.attachment&.id,
"blob_id=#{post.thumbnail.blob&.id} " \ blob_id: post.thumbnail.blob&.id,
"error_class=#{e.class} message=#{e.message}") error_class: e.class,
message: e.message }
Rails.logger.warn("PostRepr.thumbnail_url failed #{ payload.to_json }")
nil nil
end end
end end
+5 -3
ファイルの表示
@@ -1,8 +1,9 @@
class PostBulkCreator class PostBulkCreator
def initialize actor:, posts:, thumbnails: def initialize actor:, posts:, thumbnails:, host: nil
@actor_id = actor.id @actor_id = actor.id
@posts = posts @posts = posts
@thumbnails = thumbnails @thumbnails = thumbnails
@host = host
end end
def run def run
@@ -64,7 +65,8 @@ class PostBulkCreator
preflight = preflight =
PostCreatePreflight.new( PostCreatePreflight.new(
attributes: attributes, attributes: attributes,
thumbnail: thumbnail_for(index, attributes)).run thumbnail: thumbnail_for(index, attributes),
host: @host).run
if preflight[:existing_post_id].present? if preflight[:existing_post_id].present?
return { return {
status: 'skipped', status: 'skipped',
@@ -195,6 +197,6 @@ class PostBulkCreator
end end
def compact_existing_post post def compact_existing_post post
PostCompactRepr.base(post) PostCompactRepr.base(post, host: @host)
end end
end end
+18 -4
ファイルの表示
@@ -85,9 +85,11 @@ class PostCreatePlan
end_raw: match[3], end_raw: match[3],
tag_name: name) tag_name: name)
end end
raise Tag::SectionLiteralParseError.new(raw_name, raw_name) if name.include?('[') || name.include?(']') if name.include?('[') || name.include?(']')
raise Tag::SectionLiteralParseError.new(raw_name, raw_name)
end
[TagName.canonicalise(name).first, category&.to_sym, sections] [resolved_tag_name(name), category&.to_sym, sections]
end end
def build_default_tag_specs direct_tag_specs def build_default_tag_specs direct_tag_specs
@@ -201,7 +203,7 @@ class PostCreatePlan
def serialised_tags direct_tag_specs, tag_sections def serialised_tags direct_tag_specs, tag_sections
direct_tag_specs.map { |spec| direct_tag_specs.map { |spec|
"#{ spec[:name] }#{ tag_sections[spec[:name]].to_a.map { Post.section_literal(_1) }.join }" "#{ spec[:name] }#{ tag_sections[spec[:name]].to_a.map { section_literal(_1) }.join }"
}.sort.join(' ') }.sort.join(' ')
end end
@@ -210,10 +212,15 @@ class PostCreatePlan
{ {
name: spec[:name], name: spec[:name],
category: spec[:category].to_s, category: spec[:category].to_s,
section_literals: tag_sections[spec[:name]].to_a.map { Post.section_literal(_1) } } section_literals: tag_sections[spec[:name]].to_a.map { section_literal(_1) } }
}.sort_by { _1[:name] } }.sort_by { _1[:name] }
end end
def section_literal range
begin_ms, end_ms = range
"[#{ Post.ms_to_time(begin_ms) }-#{ end_ms ? Post.ms_to_time(end_ms) : '' }]"
end
def normalise_video_ms snapshot_tag_specs def normalise_video_ms snapshot_tag_specs
return nil unless snapshot_tag_specs.any? { _1[:name] == VIDEO_TAG_NAME } return nil unless snapshot_tag_specs.any? { _1[:name] == VIDEO_TAG_NAME }
@@ -254,4 +261,11 @@ class PostCreatePlan
end end
end end
end end
def resolved_tag_name name
tag_name = TagName.includes(:canonical).find_by(name:)
return name if tag_name.nil?
(tag_name.canonical || tag_name).name
end
end end
+3 -2
ファイルの表示
@@ -9,9 +9,10 @@ class PostCreatePreflight
end end
end end
def initialize attributes:, thumbnail: nil def initialize attributes:, thumbnail: nil, host: nil
@attributes = attributes.symbolize_keys @attributes = attributes.symbolize_keys
@thumbnail = thumbnail @thumbnail = thumbnail
@host = host
end end
def run def run
@@ -124,7 +125,7 @@ class PostCreatePreflight
return nil if post_id.blank? return nil if post_id.blank?
post = Post.with_attached_thumbnail.find_by(id: post_id) post = Post.with_attached_thumbnail.find_by(id: post_id)
PostCompactRepr.base(post) PostCompactRepr.base(post, host: @host)
end end
def final_field_warnings field_warnings def final_field_warnings field_warnings
+4 -3
ファイルの表示
@@ -121,9 +121,11 @@ class PostCreator
def sync_post_tags! post, desired_tags, sections def sync_post_tags! post, desired_tags, sections
desired_ids = desired_tags.map(&:id).to_set desired_ids = desired_tags.map(&:id).to_set
current_ids = post.tags.pluck(:id).to_set current_ids = post.tags.pluck(:id).to_set
Tag.where(id: desired_ids - current_ids).find_each do |tag| Tag.where(id: desired_ids - current_ids).find_each do |tag|
PostTag.create_or_find_by!(post:, tag:, created_user: @actor) PostTag.create_or_find_by!(post:, tag:, created_user: @actor)
end end
PostTagSection.where(post_id: post.id).destroy_all PostTagSection.where(post_id: post.id).destroy_all
sections.each do |tag_id, ranges| sections.each do |tag_id, ranges|
ranges.each do |begin_ms, end_ms| ranges.each do |begin_ms, end_ms|
@@ -133,10 +135,9 @@ class PostCreator
end_ms:) end_ms:)
end end
end end
PostTag.where(post_id: post.id, PostTag.where(post_id: post.id,
tag_id: (current_ids - desired_ids).to_a).kept.find_each do |post_tag| tag_id: (current_ids - desired_ids).to_a).destroy_all
post_tag.discard_by!(@actor)
end
end end
def sync_parent_posts! post, ids def sync_parent_posts! post, ids
+17 -23
ファイルの表示
@@ -3,14 +3,14 @@ require 'timeout'
class PostImportPreviewer class PostImportPreviewer
FIELDS = [ FIELDS = [
'title', 'title',
'thumbnail_base', 'thumbnail_base',
'original_created_from', 'original_created_from',
'original_created_before', 'original_created_before',
'video_ms', 'video_ms',
'duration', 'duration',
'tags', 'tags',
'parent_post_ids'].freeze 'parent_post_ids'].freeze
FETCH_WARNING_FIELDS = ['url', 'title', 'thumbnail_base'].freeze FETCH_WARNING_FIELDS = ['url', 'title', 'thumbnail_base'].freeze
TITLE_FETCH_WARNING = 'タイトルを取得できませんでした.'.freeze TITLE_FETCH_WARNING = 'タイトルを取得できませんでした.'.freeze
THUMBNAIL_FETCH_WARNING = 'サムネールを取得できませんでした.'.freeze THUMBNAIL_FETCH_WARNING = 'サムネールを取得できませんでした.'.freeze
@@ -126,7 +126,6 @@ class PostImportPreviewer
validate_basic_data(attributes, validation_errors) validate_basic_data(attributes, validation_errors)
validate_preview_tags(merged_tags(tag_sources, provenance['tags']), validate_preview_tags(merged_tags(tag_sources, provenance['tags']),
validation_errors, validation_errors,
field_warnings,
known_tags) known_tags)
validate_parents(attributes['parent_post_ids'], validation_errors, existing_parent_ids) validate_parents(attributes['parent_post_ids'], validation_errors, existing_parent_ids)
attributes.delete('url') attributes.delete('url')
@@ -226,15 +225,15 @@ class PostImportPreviewer
end end
{ data:, warnings:, validation_errors: { } } { data:, warnings:, validation_errors: { } }
rescue Preview::UrlSafety::UnsafeUrl => e rescue Preview::UrlSafety::UnsafeUrl => e
payload = { error: e.class.name, message: e.message }
Rails.logger.info( Rails.logger.info(
"post_import_metadata_fetch_unsafe_url "\ "post_import_metadata_fetch_unsafe_url #{ payload.to_json }")
"#{ { error: e.class.name, message: e.message }.to_json }")
{ data: { }, warnings: { }, validation_errors: { url: [e.message] } } { data: { }, warnings: { }, validation_errors: { url: [e.message] } }
rescue Preview::HttpFetcher::FetchFailed, rescue Preview::HttpFetcher::FetchFailed,
Preview::HttpFetcher::ResponseTooLarge => e Preview::HttpFetcher::ResponseTooLarge => e
payload = { error: e.class.name, message: e.message }
Rails.logger.info( Rails.logger.info(
"post_import_metadata_fetch_failure "\ "post_import_metadata_fetch_failure #{ payload.to_json }")
"#{ { error: e.class.name, message: e.message }.to_json }")
{ data: { }, warnings: { 'url' => [METADATA_FETCH_WARNING] }, validation_errors: { } } { data: { }, warnings: { 'url' => [METADATA_FETCH_WARNING] }, validation_errors: { } }
end end
@@ -336,14 +335,14 @@ class PostImportPreviewer
def safe_fetch_metadata url def safe_fetch_metadata url
fetch_metadata(url) fetch_metadata(url)
rescue Preview::UrlSafety::UnsafeUrl => e rescue Preview::UrlSafety::UnsafeUrl => e
payload = { error: e.class.name, message: e.message }
Rails.logger.info( Rails.logger.info(
"post_import_metadata_fetch_unsafe_url "\ "post_import_metadata_fetch_unsafe_url #{ payload.to_json }")
"#{ { error: e.class.name, message: e.message }.to_json }")
{ data: { }, warnings: { }, validation_errors: { url: [e.message] } } { data: { }, warnings: { }, validation_errors: { url: [e.message] } }
rescue StandardError => e rescue StandardError => e
payload = { error: e.class.name, message: e.message }
Rails.logger.error( Rails.logger.error(
"post_import_metadata_fetch_unexpected_failure "\ "post_import_metadata_fetch_unexpected_failure #{ payload.to_json }")
"#{ { error: e.class.name, message: e.message }.to_json }")
{ data: { }, warnings: { 'url' => [METADATA_FETCH_WARNING] }, validation_errors: { } } { data: { }, warnings: { 'url' => [METADATA_FETCH_WARNING] }, validation_errors: { } }
end end
@@ -450,7 +449,7 @@ class PostImportPreviewer
[] []
end end
def validate_preview_tags raw, errors, field_warnings, known_tags def validate_preview_tags raw, errors, known_tags
names = raw.to_s.split names = raw.to_s.split
return if names.empty? return if names.empty?
if names.any? { _1.downcase.start_with?('nico:') } if names.any? { _1.downcase.start_with?('nico:') }
@@ -462,11 +461,6 @@ class PostImportPreviewer
existing = parsed.filter_map { known_tags[_1] } existing = parsed.filter_map { known_tags[_1] }
deprecated = existing.select(&:deprecated?).map(&:name) deprecated = existing.select(&:deprecated?).map(&:name)
errors[:tags] = ["廃止済みタグがあります: #{ deprecated.join(' ') }"] if deprecated.present? errors[:tags] = ["廃止済みタグがあります: #{ deprecated.join(' ') }"] if deprecated.present?
known = existing.reject(&:deprecated?).map(&:name)
new_tags = parsed.uniq - known
if new_tags.present?
add_field_warning!(field_warnings, 'tags', "新規タグを作成します: #{ new_tags.join(' ') }")
end
rescue Tag::SectionLiteralParseError rescue Tag::SectionLiteralParseError
errors[:tags] = ['タグ区間の記法が不正です.'] errors[:tags] = ['タグ区間の記法が不正です.']
end end
+3 -4
ファイルの表示
@@ -3,10 +3,9 @@ require 'date'
class PostMetadataFetcher class PostMetadataFetcher
TIMESTAMP_PATTERN = TIMESTAMP_PATTERN =
Regexp.new( /\A(\d{4})-(\d{2})-(\d{2})T(\d{2})
'\A(\d{4})-(\d{2})-(\d{2})T(\d{2})' \ (?::(\d{2})(?::(\d{2})(?:\.(\d+))?)?)?
'(?::(\d{2})(?::(\d{2})(?:\.(\d+))?)?)?' \ (Z|[+-]\d{2}:?\d{2})?\z/x
'(Z|[+-]\d{2}:?\d{2})?\z')
def self.fetch raw_url def self.fetch raw_url
uri, = Preview::UrlSafety.validate(raw_url) uri, = Preview::UrlSafety.validate(raw_url)
+1
ファイルの表示
@@ -25,6 +25,7 @@ class PostVersionRecorder < VersionRecorder
thumbnail_base: @record.thumbnail_base, thumbnail_base: @record.thumbnail_base,
video_ms: @record.video_ms, video_ms: @record.video_ms,
tags: @record.snapshot_tag_names.join(' '), tags: @record.snapshot_tag_names.join(' '),
tags_json: @record.snapshot_tags_json,
parent_post_ids: @record.snapshot_parent_post_ids.join(' '), parent_post_ids: @record.snapshot_parent_post_ids.join(' '),
original_created_from: @record.original_created_from, original_created_from: @record.original_created_from,
original_created_before: @record.original_created_before } original_created_before: @record.original_created_before }
+3 -3
ファイルの表示
@@ -103,7 +103,7 @@ module Youtube
end end
def sync_post_tags! post, desired_tag_ids, current_tag_ids: nil def sync_post_tags! post, desired_tag_ids, current_tag_ids: nil
current_tag_ids ||= PostTag.kept.where(post_id: post.id).pluck(:tag_id).to_set current_tag_ids ||= PostTag.where(post_id: post.id).pluck(:tag_id).to_set
desired_tag_ids = desired_tag_ids.compact.to_set desired_tag_ids = desired_tag_ids.compact.to_set
to_add = desired_tag_ids - current_tag_ids to_add = desired_tag_ids - current_tag_ids
@@ -117,8 +117,8 @@ module Youtube
end end
end end
PostTag.where(post_id: post.id, tag_id: to_remove.to_a).kept.find_each do |pt| PostTag.where(post_id: post.id, tag_id: to_remove.to_a).find_each do |pt|
pt.discard_by!(nil) pt.destroy!
end end
end end
+3 -3
ファイルの表示
@@ -1,4 +1,4 @@
require "active_support/core_ext/integer/time" require 'active_support/core_ext/integer/time'
Rails.application.configure do Rails.application.configure do
# Settings specified here will take precedence over those in config/application.rb. # Settings specified here will take precedence over those in config/application.rb.
@@ -17,8 +17,8 @@ Rails.application.configure do
# Enable/disable Action Controller caching. By default Action Controller caching is disabled. # Enable/disable Action Controller caching. By default Action Controller caching is disabled.
# Run rails dev:cache to toggle Action Controller caching. # Run rails dev:cache to toggle Action Controller caching.
if Rails.root.join("tmp/caching-dev.txt").exist? if Rails.root.join('tmp/caching-dev.txt').exist?
config.public_file_server.headers = { "cache-control" => "public, max-age=#{2.days.to_i}" } config.public_file_server.headers = { 'cache-control' => "public, max-age=#{2.days.to_i}" }
else else
config.action_controller.perform_caching = false config.action_controller.perform_caching = false
end end
-1
ファイルの表示
@@ -55,7 +55,6 @@ Rails.application.routes.draw do
get :metadata get :metadata
post :bulk post :bulk
get :random get :random
get :changes
get :versions, to: 'post_versions#index' get :versions, to: 'post_versions#index'
end end
+299
ファイルの表示
@@ -0,0 +1,299 @@
class AddTagsJsonToPostVersions < ActiveRecord::Migration[8.0]
RESOLUTION_GRACE = 1.second
SECTION_LITERAL_PATTERN = /\[[^\[\]\s]*-[^\[\]\s]*\]\z/
class MigrationPostVersion < ActiveRecord::Base
self.table_name = 'post_versions'
end
class MigrationTag < ActiveRecord::Base
self.table_name = 'tags'
end
class MigrationTagName < ActiveRecord::Base
self.table_name = 'tag_names'
end
class MigrationTagVersion < ActiveRecord::Base
self.table_name = 'tag_versions'
end
class MigrationNicoTagVersion < ActiveRecord::Base
self.table_name = 'nico_tag_versions'
end
def up
add_column :post_versions, :tags_json, :json, after: :tags
MigrationPostVersion.reset_column_information
backfill_missing_initial_tag_versions!
intervals_by_name = build_intervals_by_name
say_with_time 'Backfilling post_versions.tags_json' do
MigrationPostVersion.where(tags_json: nil).find_each(batch_size: 500) do |version|
version.update_columns(tags_json: build_tags_json(version, intervals_by_name))
end
end
change_column_null :post_versions, :tags_json, false
schema = connection.quote(JSON.generate({
type: 'array',
items: { type: 'object',
properties: { id: { type: 'integer', minimum: 1 },
version_no: { type: 'integer', minimum: 1 },
name: { type: 'string', minLength: 1 },
category: { type: 'string', enum: ['deerjikist',
'meme',
'character',
'general',
'material',
'meta',
'nico'] },
sections: { type: 'array',
items: { type: 'object',
properties: { begin_ms: { type: 'integer',
minimum: 0 },
end_ms: { type: ['integer',
'null'],
minimum: 0 } },
required: ['begin_ms', 'end_ms'],
additionalProperties: false } } },
required: ['id', 'version_no', 'name', 'category', 'sections'],
additionalProperties: false } }))
add_check_constraint :post_versions,
"JSON_SCHEMA_VALID(#{ schema }, tags_json)",
name: 'chk_post_versions_tags_json_schema'
end
def down
remove_check_constraint :post_versions, name: 'chk_post_versions_tags_json_schema'
remove_column :post_versions, :tags_json
end
private
def backfill_missing_initial_tag_versions!
say_with_time 'Backfilling missing initial tag versions' do
tag_rows = missing_initial_version_rows(MigrationTagVersion, nico: false)
nico_rows = missing_initial_version_rows(MigrationNicoTagVersion, nico: true)
MigrationTagVersion.insert_all!(tag_rows) if tag_rows.any?
MigrationNicoTagVersion.insert_all!(nico_rows) if nico_rows.any?
tag_rows.length + nico_rows.length
end
end
def missing_initial_version_rows version_class, nico:
first_versions =
version_class
.order(:tag_id, :version_no)
.to_a
.group_by(&:tag_id)
.transform_values(&:first)
rows = []
MigrationTag.find_each do |tag|
next if (tag.category == 'nico') != nico
first_version = first_versions[tag.id]
next unless first_version
next if valid_initial_version?(first_version)
assert_inferable_initial_version!(tag, first_version)
rows << initial_version_row(tag, first_version, nico:)
end
rows
end
def valid_initial_version? version
version.version_no == 1 && version.event_type == 'create'
end
def assert_inferable_initial_version! tag, version
inferable =
version.version_no == 2 &&
version.event_type == 'discard' &&
tag.created_at < version.created_at
return if inferable
details = [
"tag_id=#{ tag.id }",
"version_no=#{ version.version_no }",
"event_type=#{ version.event_type.inspect }",
"tag_created_at=#{ tag.created_at.iso8601(6) }",
"version_created_at=#{ version.created_at.iso8601(6) }"]
raise "Cannot infer initial tag version: #{ details.join(', ') }"
end
def initial_version_row tag, discard_version, nico:
row = {
tag_id: tag.id,
version_no: 1,
event_type: 'create',
name: discard_version.name,
created_at: tag.created_at,
created_by_user_id: nil }
if nico
return row.merge(linked_tags: discard_version.linked_tags)
end
row.merge(
category: discard_version.category,
aliases: discard_version.aliases,
parent_tag_ids: discard_version.parent_tag_ids,
deprecated_at: discard_version.deprecated_at)
end
def build_intervals_by_name
intervals_by_name = Hash.new { |hash, name| hash[name] = [] }
versions_by_kind = {
tag: versions_by_tag_id(MigrationTagVersion),
nico: versions_by_tag_id(MigrationNicoTagVersion) }
current_names = current_names_by_tag_id
MigrationTag.find_each do |tag|
nico = tag.category == 'nico'
kind = nico ? :nico : :tag
versions = versions_by_kind.fetch(kind).fetch(tag.id, [])
intervals_for(
tag,
versions,
current_name: current_names.fetch(tag.id),
nico:).each do |interval|
name = interval.delete(:name)
intervals_by_name[name] << interval
end
end
intervals_by_name
end
def versions_by_tag_id version_class
version_class
.order(:tag_id, :version_no)
.to_a
.group_by(&:tag_id)
end
def current_names_by_tag_id
MigrationTagName
.joins('INNER JOIN tags ON tags.tag_name_id = tag_names.id')
.pluck('tags.id', 'tag_names.name')
.to_h
end
def intervals_for tag, versions, current_name:, nico:
if versions.empty?
return [{
name: current_name,
tag_id: tag.id,
version_no: tag.version_no,
category: nico ? 'nico' : tag.category,
from: tag.created_at,
to: tag.discarded_at }]
end
versions.each_with_index.filter_map do |version, index|
next if version.event_type == 'discard'
{
name: version.name,
tag_id: tag.id,
version_no: version.version_no,
category: nico ? 'nico' : version.category,
from: version.created_at,
to: versions[index + 1]&.created_at || tag.discarded_at }
end
end
def build_tags_json version, intervals_by_name
entries = version.tags.to_s.split.map do |literal|
name = tag_name_from_literal(literal)
interval = resolve_tag!(intervals_by_name.fetch(name, []), name:, version:)
{ 'id' => interval.fetch(:tag_id),
'version_no' => interval.fetch(:version_no),
'name' => name,
'category' => interval.fetch(:category),
'sections' => [] }
end
assert_unique_tag_ids!(version, entries)
entries.sort_by { |entry| entry.fetch('id') }
end
def tag_name_from_literal literal
name = literal.dup
name.sub!(SECTION_LITERAL_PATTERN, '') while name.match?(
SECTION_LITERAL_PATTERN)
if name.empty? || name.include?('[') || name.include?(']')
raise "Invalid legacy tag literal: #{ literal.inspect }"
end
name
end
def resolve_tag! intervals, name:, version:
time = version.created_at
candidates = intervals.select do |interval|
interval.fetch(:from) <= time &&
(interval[:to].nil? || time < interval.fetch(:to))
end
candidates = future_candidates(intervals, time) if candidates.empty?
return candidates.first if candidates.one?
candidate_versions = candidates.map do |candidate|
[candidate.fetch(:tag_id), candidate.fetch(:version_no)]
end
details = [
"post_version_id=#{ version.id }",
"post_id=#{ version.post_id }",
"name=#{ name.inspect }",
"created_at=#{ time.iso8601(6) }",
"candidates=#{ candidate_versions.inspect }"].join(', ')
raise "Could not resolve tag snapshot: #{ details }"
end
def future_candidates intervals, time
candidates = intervals.select do |interval|
interval.fetch(:from) > time &&
interval.fetch(:from) <= time + RESOLUTION_GRACE
end
return [] if candidates.empty?
nearest_from = candidates.map { |interval| interval.fetch(:from) }.min
candidates.select do |interval|
interval.fetch(:from) == nearest_from
end
end
def assert_unique_tag_ids! version, entries
duplicate_tag_ids =
entries
.map { |entry| entry.fetch('id') }
.tally
.select { |_tag_id, count| count > 1 }
.keys
return if duplicate_tag_ids.empty?
details = [
"post_version_id=#{ version.id }",
"duplicate_tag_ids=#{ duplicate_tag_ids.inspect }"].join(', ')
raise "Duplicate tag IDs: #{ details }"
end
end
+53
ファイルの表示
@@ -0,0 +1,53 @@
class DeleteInactiveRecordsFromPostTags < ActiveRecord::Migration[8.0]
def up
execute <<~SQL
DELETE
FROM
post_tags
WHERE
discarded_at IS NOT NULL
SQL
remove_index :post_tags, [:tag_id, :discarded_at]
remove_index :post_tags, [:post_id, :discarded_at]
remove_index :post_tags, name: 'idx_post_tags_active_unique'
remove_index :post_tags, :discarded_at
remove_foreign_key :post_tags, column: :deleted_user_id
remove_index :post_tags, :deleted_user_id
remove_column :post_tags, :active_unique_key
remove_column :post_tags, :is_active
remove_column :post_tags, :discarded_at
remove_column :post_tags, :deleted_user_id
remove_column :post_tags, :updated_at
execute <<~SQL
ALTER TABLE
post_tags
MODIFY COLUMN
id BIGINT NOT NULL
SQL
execute <<~SQL
ALTER TABLE
post_tags
DROP PRIMARY KEY
SQL
remove_column :post_tags, :id
execute <<~SQL
ALTER TABLE
post_tags
ADD PRIMARY KEY
(post_id, tag_id)
SQL
remove_index :post_tags, :post_id
end
def down
raise ActiveRecord::IrreversibleMigration, '戻せません.'
end
end
@@ -0,0 +1,11 @@
class AddForeignKeyOnPostIdAndTagIdInPostTagSections < ActiveRecord::Migration[8.0]
def change
remove_foreign_key :post_tag_sections, :posts, column: :post_id
remove_foreign_key :post_tag_sections, :tags, column: :tag_id
add_foreign_key :post_tag_sections, :post_tags,
column: [:post_id, :tag_id],
primary_key: [:post_id, :tag_id],
on_delete: :cascade
end
end
+46
ファイルの表示
@@ -0,0 +1,46 @@
class DeleteDiscardedRecordsFromTags < ActiveRecord::Migration[8.0]
def up
remove_foreign_key :tag_versions, :tags, column: :tag_id
remove_foreign_key :nico_tag_versions, :tags, column: :tag_id
remove_foreign_key :material_versions, :tags, column: :tag_id
execute <<~SQL
DELETE
ntr
FROM
nico_tag_relations ntr
INNER JOIN
tags t
ON
t.discarded_at IS NOT NULL
AND t.id IN (ntr.tag_id, ntr.nico_tag_id)
SQL
execute <<~SQL
DELETE
ti
FROM
tag_implications ti
INNER JOIN
tags t
ON
t.discarded_at IS NOT NULL
AND t.id IN (ti.tag_id, ti.parent_tag_id)
SQL
execute <<~SQL
DELETE
FROM
tags
WHERE
discarded_at IS NOT NULL
SQL
remove_index :tags, :discarded_at
remove_column :tags, :discarded_at
end
def down
raise ActiveRecord::IrreversibleMigration, '戻せません.'
end
end
+18
ファイルの表示
@@ -0,0 +1,18 @@
class DeleteDiscardedRecordsFromTagNames < ActiveRecord::Migration[8.0]
def up
execute <<~SQL
DELETE
FROM
tag_names
WHERE
discarded_at IS NOT NULL
SQL
remove_index :tag_names, :discarded_at
remove_column :tag_names, :discarded_at
end
def down
raise ActiveRecord::IrreversibleMigration, '戻せません.'
end
end
生成ファイル
+18 -36
ファイルの表示
@@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.0].define(version: 2026_07_13_000000) do ActiveRecord::Schema[8.0].define(version: 2026_09_21_030000) do
create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.string "name", null: false t.string "name", null: false
t.string "record_type", null: false t.string "record_type", null: false
@@ -311,23 +311,12 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_13_000000) do
t.check_constraint "`begin_ms` >= 0", name: "chk_post_tag_sections_begin_ms_natural" t.check_constraint "`begin_ms` >= 0", name: "chk_post_tag_sections_begin_ms_natural"
end end
create_table "post_tags", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| create_table "post_tags", primary_key: ["post_id", "tag_id"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "post_id", null: false t.bigint "post_id", null: false
t.bigint "tag_id", null: false t.bigint "tag_id", null: false
t.bigint "created_user_id" t.bigint "created_user_id"
t.bigint "deleted_user_id"
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.datetime "discarded_at"
t.virtual "is_active", type: :boolean, as: "(`discarded_at` is null)", stored: true
t.virtual "active_unique_key", type: :string, as: "(case when (`discarded_at` is null) then concat(`post_id`,_utf8mb4':',`tag_id`) else NULL end)", stored: true
t.index ["active_unique_key"], name: "idx_post_tags_active_unique", unique: true
t.index ["created_user_id"], name: "index_post_tags_on_created_user_id" t.index ["created_user_id"], name: "index_post_tags_on_created_user_id"
t.index ["deleted_user_id"], name: "index_post_tags_on_deleted_user_id"
t.index ["discarded_at"], name: "index_post_tags_on_discarded_at"
t.index ["post_id", "discarded_at"], name: "index_post_tags_on_post_id_and_discarded_at"
t.index ["post_id"], name: "index_post_tags_on_post_id"
t.index ["tag_id", "discarded_at"], name: "index_post_tags_on_tag_id_and_discarded_at"
t.index ["tag_id"], name: "index_post_tags_on_tag_id" t.index ["tag_id"], name: "index_post_tags_on_tag_id"
end end
@@ -349,6 +338,7 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_13_000000) do
t.string "url", limit: 768, null: false t.string "url", limit: 768, null: false
t.string "thumbnail_base", limit: 2000 t.string "thumbnail_base", limit: 2000
t.text "tags", null: false t.text "tags", null: false
t.json "tags_json", null: false
t.text "parent_post_ids", null: false t.text "parent_post_ids", null: false
t.datetime "original_created_from" t.datetime "original_created_from"
t.datetime "original_created_before" t.datetime "original_created_before"
@@ -362,6 +352,7 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_13_000000) do
t.check_constraint "(`video_ms` is null) or (`video_ms` > 0)", name: "chk_post_versions_video_ms_positive" t.check_constraint "(`video_ms` is null) or (`video_ms` > 0)", name: "chk_post_versions_video_ms_positive"
t.check_constraint "`event_type` in (_utf8mb4'create',_utf8mb4'update',_utf8mb4'discard',_utf8mb4'restore')", name: "post_versions_event_type_valid" t.check_constraint "`event_type` in (_utf8mb4'create',_utf8mb4'update',_utf8mb4'discard',_utf8mb4'restore')", name: "post_versions_event_type_valid"
t.check_constraint "`version_no` > 0", name: "post_versions_version_no_positive" t.check_constraint "`version_no` > 0", name: "post_versions_version_no_positive"
t.check_constraint "json_schema_valid(_utf8mb4'{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"id\":{\"type\":\"integer\",\"minimum\":1},\"version_no\":{\"type\":\"integer\",\"minimum\":1},\"name\":{\"type\":\"string\",\"minLength\":1},\"category\":{\"type\":\"string\",\"enum\":[\"deerjikist\",\"meme\",\"character\",\"general\",\"material\",\"meta\",\"nico\"]},\"sections\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"begin_ms\":{\"type\":\"integer\",\"minimum\":0},\"end_ms\":{\"type\":[\"integer\",\"null\"],\"minimum\":0}},\"required\":[\"begin_ms\",\"end_ms\"],\"additionalProperties\":false}}},\"required\":[\"id\",\"version_no\",\"name\",\"category\",\"sections\"],\"additionalProperties\":false}}',`tags_json`)", name: "chk_post_versions_tags_json_schema"
end end
create_table "posts", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| create_table "posts", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
@@ -393,19 +384,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_13_000000) do
t.index ["user_id"], name: "index_settings_on_user_id", unique: true t.index ["user_id"], name: "index_settings_on_user_id", unique: true
end end
create_table "wiki_assets", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "wiki_page_id", null: false
t.integer "no", null: false
t.string "alt_text"
t.binary "sha256", limit: 32, null: false
t.bigint "created_by_user_id", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["created_by_user_id"], name: "index_wiki_assets_on_created_by_user_id"
t.index ["wiki_page_id", "no"], name: "index_wiki_assets_on_wiki_page_id_and_no", unique: true
t.index ["wiki_page_id", "sha256"], name: "index_wiki_assets_on_wiki_page_id_and_sha256", unique: true
end
create_table "tag_implications", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| create_table "tag_implications", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "tag_id", null: false t.bigint "tag_id", null: false
t.bigint "parent_tag_id", null: false t.bigint "parent_tag_id", null: false
@@ -431,9 +409,7 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_13_000000) do
t.bigint "canonical_id" t.bigint "canonical_id"
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.datetime "discarded_at"
t.index ["canonical_id"], name: "index_tag_names_on_canonical_id" t.index ["canonical_id"], name: "index_tag_names_on_canonical_id"
t.index ["discarded_at"], name: "index_tag_names_on_discarded_at"
t.index ["name"], name: "index_tag_names_on_name", unique: true t.index ["name"], name: "index_tag_names_on_name", unique: true
end end
@@ -470,10 +446,8 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_13_000000) do
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.integer "post_count", default: 0, null: false t.integer "post_count", default: 0, null: false
t.datetime "discarded_at"
t.integer "version_no", null: false t.integer "version_no", null: false
t.index ["deprecated_at"], name: "index_tags_on_deprecated_at" t.index ["deprecated_at"], name: "index_tags_on_deprecated_at"
t.index ["discarded_at"], name: "index_tags_on_discarded_at"
t.index ["tag_name_id"], name: "index_tags_on_tag_name_id", unique: true t.index ["tag_name_id"], name: "index_tags_on_tag_name_id", unique: true
t.check_constraint "(`deprecated_at` is null) or (`category` <> _utf8mb4'nico')", name: "chk_tags_deprecated_at_not_nico" t.check_constraint "(`deprecated_at` is null) or (`category` <> _utf8mb4'nico')", name: "chk_tags_deprecated_at_not_nico"
t.check_constraint "`version_no` > 0", name: "chk_tags_version_no_positive" t.check_constraint "`version_no` > 0", name: "chk_tags_version_no_positive"
@@ -614,6 +588,19 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_13_000000) do
t.index ["banned_at"], name: "index_users_on_banned_at" t.index ["banned_at"], name: "index_users_on_banned_at"
end end
create_table "wiki_assets", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "wiki_page_id", null: false
t.integer "no", null: false
t.string "alt_text"
t.binary "sha256", limit: 32, null: false
t.bigint "created_by_user_id", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["created_by_user_id"], name: "index_wiki_assets_on_created_by_user_id"
t.index ["wiki_page_id", "no"], name: "index_wiki_assets_on_wiki_page_id_and_no", unique: true
t.index ["wiki_page_id", "sha256"], name: "index_wiki_assets_on_wiki_page_id_and_sha256", unique: true
end
create_table "wiki_lines", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| create_table "wiki_lines", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.string "sha256", limit: 64, null: false t.string "sha256", limit: 64, null: false
t.text "body", null: false t.text "body", null: false
@@ -707,7 +694,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_13_000000) do
add_foreign_key "material_sync_suppressions", "users", column: "created_by_user_id" add_foreign_key "material_sync_suppressions", "users", column: "created_by_user_id"
add_foreign_key "material_versions", "materials" add_foreign_key "material_versions", "materials"
add_foreign_key "material_versions", "materials", column: "parent_id" add_foreign_key "material_versions", "materials", column: "parent_id"
add_foreign_key "material_versions", "tags"
add_foreign_key "material_versions", "users", column: "created_by_user_id" add_foreign_key "material_versions", "users", column: "created_by_user_id"
add_foreign_key "material_versions", "users", column: "updated_by_user_id" add_foreign_key "material_versions", "users", column: "updated_by_user_id"
add_foreign_key "materials", "materials", column: "parent_id" add_foreign_key "materials", "materials", column: "parent_id"
@@ -716,18 +702,15 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_13_000000) do
add_foreign_key "materials", "users", column: "updated_by_user_id" add_foreign_key "materials", "users", column: "updated_by_user_id"
add_foreign_key "nico_tag_relations", "tags" add_foreign_key "nico_tag_relations", "tags"
add_foreign_key "nico_tag_relations", "tags", column: "nico_tag_id" add_foreign_key "nico_tag_relations", "tags", column: "nico_tag_id"
add_foreign_key "nico_tag_versions", "tags"
add_foreign_key "nico_tag_versions", "users", column: "created_by_user_id" add_foreign_key "nico_tag_versions", "users", column: "created_by_user_id"
add_foreign_key "post_implications", "posts" add_foreign_key "post_implications", "posts"
add_foreign_key "post_implications", "posts", column: "parent_post_id" add_foreign_key "post_implications", "posts", column: "parent_post_id"
add_foreign_key "post_similarities", "posts" add_foreign_key "post_similarities", "posts"
add_foreign_key "post_similarities", "posts", column: "target_post_id" add_foreign_key "post_similarities", "posts", column: "target_post_id"
add_foreign_key "post_tag_sections", "posts" add_foreign_key "post_tag_sections", "post_tags", column: ["post_id", "tag_id"], primary_key: ["post_id", "tag_id"], on_delete: :cascade
add_foreign_key "post_tag_sections", "tags"
add_foreign_key "post_tags", "posts" add_foreign_key "post_tags", "posts"
add_foreign_key "post_tags", "tags" add_foreign_key "post_tags", "tags"
add_foreign_key "post_tags", "users", column: "created_user_id" add_foreign_key "post_tags", "users", column: "created_user_id"
add_foreign_key "post_tags", "users", column: "deleted_user_id"
add_foreign_key "post_versions", "posts" add_foreign_key "post_versions", "posts"
add_foreign_key "post_versions", "users", column: "created_by_user_id" add_foreign_key "post_versions", "users", column: "created_by_user_id"
add_foreign_key "posts", "users", column: "uploaded_user_id" add_foreign_key "posts", "users", column: "uploaded_user_id"
@@ -737,7 +720,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_13_000000) do
add_foreign_key "tag_names", "tag_names", column: "canonical_id" add_foreign_key "tag_names", "tag_names", column: "canonical_id"
add_foreign_key "tag_similarities", "tags" add_foreign_key "tag_similarities", "tags"
add_foreign_key "tag_similarities", "tags", column: "target_tag_id" add_foreign_key "tag_similarities", "tags", column: "target_tag_id"
add_foreign_key "tag_versions", "tags"
add_foreign_key "tag_versions", "users", column: "created_by_user_id" add_foreign_key "tag_versions", "users", column: "created_by_user_id"
add_foreign_key "tags", "tag_names" add_foreign_key "tags", "tag_names"
add_foreign_key "theatre_comments", "theatres" add_foreign_key "theatre_comments", "theatres"
+3 -3
ファイルの表示
@@ -16,7 +16,7 @@ namespace :nico do
end end
def sync_post_tags! post, desired_tag_ids, current_tag_ids: nil def sync_post_tags! post, desired_tag_ids, current_tag_ids: nil
current_tag_ids ||= PostTag.kept.where(post_id: post.id).pluck(:tag_id).to_set current_tag_ids ||= PostTag.where(post_id: post.id).pluck(:tag_id).to_set
desired_tag_ids = desired_tag_ids.compact.to_set desired_tag_ids = desired_tag_ids.compact.to_set
to_add = desired_tag_ids - current_tag_ids to_add = desired_tag_ids - current_tag_ids
@@ -30,8 +30,8 @@ namespace :nico do
end end
end end
PostTag.where(post_id: post.id, tag_id: to_remove.to_a).kept.find_each do |pt| PostTag.where(post_id: post.id, tag_id: to_remove.to_a).find_each do |pt|
pt.discard_by!(nil) pt.destroy!
end end
end end
+123
ファイルの表示
@@ -0,0 +1,123 @@
require 'rails_helper'
require_relative '../../db/migrate/20260921020000_delete_discarded_records_from_tags'
require_relative '../../db/migrate/20260921030000_delete_discarded_records_from_tag_names'
RSpec.describe 'discarded tag cleanup migrations' do
[DeleteDiscardedRecordsFromTags, DeleteDiscardedRecordsFromTagNames].each do |migration_class|
it "rejects rollback of #{ migration_class.name }" do
expect { migration_class.new.down }
.to raise_error(ActiveRecord::IrreversibleMigration)
end
end
context 'with legacy records' do
self.use_transactional_tests = false
before do
record_class = Class.new(ActiveRecord::Base) do
self.abstract_class = true
end
stub_const('TagCleanupMigrationRecord', record_class)
config = ActiveRecord::Base.connection_db_config.configuration_hash
@database = "btrc_hub_test_tag_cleanup_#{ Process.pid }_#{ SecureRandom.hex(4) }"
record_class.establish_connection(config.merge(database: nil))
@connection = record_class.lease_connection
@connection.create_database(@database)
@database_created = true
@connection.execute("USE #{ @connection.quote_table_name(@database) }")
end
after do
@connection.drop_database(@database) if @database_created
ensure
TagCleanupMigrationRecord.remove_connection
end
before do
@connection.create_table(:tag_names) do |t|
t.string :name, null: false, index: { unique: true }
t.bigint :canonical_id
t.datetime :discarded_at, index: true
end
@connection.add_foreign_key(:tag_names, :tag_names, column: :canonical_id)
@connection.create_table(:tags) do |t|
t.references :tag_name, null: false, foreign_key: true, index: { unique: true }
t.datetime :discarded_at, index: true
end
@connection.create_table(:nico_tag_relations) do |t|
t.references :tag, null: false, foreign_key: true
t.references :nico_tag, null: false, foreign_key: { to_table: :tags }
end
@connection.create_table(:tag_implications) do |t|
t.references :tag, null: false, foreign_key: true
t.references :parent_tag, null: false, foreign_key: { to_table: :tags }
end
[:tag_versions, :nico_tag_versions, :material_versions].each do |table|
@connection.create_table(table) do |t|
t.references :tag, null: false, foreign_key: true
end
end
@connection.execute(<<~SQL)
INSERT INTO tag_names (id, name, canonical_id, discarded_at) VALUES
(1, 'kept', NULL, NULL),
(2, 'merged_alias', 1, NULL),
(3, 'nico:deleted', NULL, '2026-09-20'),
(4, 'nico:kept', NULL, NULL),
(5, 'deleted_name', NULL, '2026-09-20')
SQL
@connection.execute(<<~SQL)
INSERT INTO tags (id, tag_name_id, discarded_at) VALUES
(1, 1, NULL), (2, 2, '2026-09-20'),
(3, 3, '2026-09-20'), (4, 4, NULL)
SQL
@connection.execute(<<~SQL)
INSERT INTO nico_tag_relations (id, tag_id, nico_tag_id) VALUES
(1, 1, 4), (2, 2, 4), (3, 1, 3), (4, 2, 3)
SQL
@connection.execute(<<~SQL)
INSERT INTO tag_implications (id, tag_id, parent_tag_id) VALUES
(1, 1, 4), (2, 2, 1), (3, 1, 2), (4, 2, 3)
SQL
@connection.execute('INSERT INTO tag_versions (tag_id) VALUES (1), (2)')
@connection.execute('INSERT INTO nico_tag_versions (tag_id) VALUES (3), (4)')
@connection.execute('INSERT INTO material_versions (tag_id) VALUES (1), (2)')
end
it 'removes discarded records and their links while retaining aliases and history' do
[DeleteDiscardedRecordsFromTags, DeleteDiscardedRecordsFromTagNames].each do |klass|
migration = klass.new
allow(migration).to receive(:connection).and_return(@connection)
migration.suppress_messages { migration.up }
end
expect(@connection.select_values('SELECT id FROM tags ORDER BY id')).to eq([1, 4])
expect(@connection.select_rows('SELECT id, canonical_id FROM tag_names ORDER BY id'))
.to eq([[1, nil], [2, 1], [4, nil]])
expect(@connection.select_values('SELECT id FROM nico_tag_relations')).to eq([1])
expect(@connection.select_values('SELECT id FROM tag_implications')).to eq([1])
expect(@connection.select_values('SELECT tag_id FROM tag_versions ORDER BY tag_id'))
.to eq([1, 2])
expect(@connection.select_values('SELECT tag_id FROM nico_tag_versions ORDER BY tag_id'))
.to eq([3, 4])
expect(@connection.select_values('SELECT tag_id FROM material_versions ORDER BY tag_id'))
.to eq([1, 2])
[:tags, :tag_names].each do |table|
expect(@connection.column_exists?(table, :discarded_at)).to be(false)
expect(@connection.index_exists?(table, :discarded_at)).to be(false)
end
[:tag_versions, :nico_tag_versions, :material_versions].each do |table|
expect(@connection.foreign_key_exists?(table, :tags, column: :tag_id)).to be(false)
end
expect(@connection.foreign_key_exists?(:tags, :tag_names)).to be(true)
expect(@connection.foreign_key_exists?(:tag_names, :tag_names, column: :canonical_id))
.to be(true)
expect(@connection.index_exists?(:tag_names, :name, unique: true)).to be(true)
expect(@connection.index_exists?(:tags, :tag_name_id, unique: true)).to be(true)
[:nico_tag_relations, :tag_implications].each do |table|
expect(@connection.foreign_keys(table).map(&:to_table)).to eq(['tags', 'tags'])
end
end
end
end
+11 -11
ファイルの表示
@@ -110,7 +110,7 @@ RSpec.describe Post, type: :model do
end end
describe '.resized_thumbnail_attachment' do describe '.resized_thumbnail_attachment' do
it 'centre-crops a wide image to 180x180 without distorting it' do it 'fits a wide image within 180x180 without distorting it' do
blob = image_blob( blob = image_blob(
width: 360, width: 360,
height: 180, height: 180,
@@ -123,13 +123,13 @@ RSpec.describe Post, type: :model do
resized = described_class.resized_thumbnail_attachment(upload_for(blob)) resized = described_class.resized_thumbnail_attachment(upload_for(blob))
image = read_image(resized) image = read_image(resized)
expect(image.dimensions).to eq([180, 180]) expect(image.dimensions).to eq([180, 90])
expect_green(colour_at(image, 0, 90)) expect_red(colour_at(image, 0, 45))
expect_green(colour_at(image, 90, 90)) expect_green(colour_at(image, 90, 45))
expect_green(colour_at(image, 179, 90)) expect_red(colour_at(image, 179, 45))
end end
it 'centre-crops a tall image to 180x180 without distorting it' do it 'fits a tall image within 180x180 without distorting it' do
blob = image_blob( blob = image_blob(
width: 180, width: 180,
height: 360, height: 360,
@@ -142,10 +142,10 @@ RSpec.describe Post, type: :model do
resized = described_class.resized_thumbnail_attachment(upload_for(blob)) resized = described_class.resized_thumbnail_attachment(upload_for(blob))
image = read_image(resized) image = read_image(resized)
expect(image.dimensions).to eq([180, 180]) expect(image.dimensions).to eq([90, 180])
expect_green(colour_at(image, 90, 0)) expect_red(colour_at(image, 45, 0))
expect_green(colour_at(image, 90, 90)) expect_green(colour_at(image, 45, 90))
expect_green(colour_at(image, 90, 179)) expect_red(colour_at(image, 45, 179))
end end
it 'keeps a square image square without distortion' do it 'keeps a square image square without distortion' do
@@ -192,7 +192,7 @@ RSpec.describe Post, type: :model do
expect(post.thumbnail).to be_attached expect(post.thumbnail).to be_attached
image = read_image(post.thumbnail) image = read_image(post.thumbnail)
expect(image.dimensions).to eq([180, 180]) expect(image.dimensions).to eq([180, 135])
end end
it 'does not attach anything when thumbnail conversion fails' do it 'does not attach anything when thumbnail conversion fails' do
+79 -4
ファイルの表示
@@ -1,5 +1,73 @@
require 'rails_helper'
RSpec.describe PostTag, type: :model do RSpec.describe PostTag, type: :model do
describe 'uniqueness' do
it 'rejects duplicate post and tag pairs but allows either to be reused' do
post_tag = create(:post_tag)
duplicate = build(:post_tag, post: post_tag.post, tag: post_tag.tag)
expect(duplicate).not_to be_valid
expect(duplicate.errors.of_kind?(:post_id, :taken)).to be(true)
expect(build(:post_tag, post: post_tag.post, tag: create(:tag))).to be_valid
expect(build(:post_tag, post: create(:post), tag: post_tag.tag)).to be_valid
end
it 'enforces uniqueness in the database when validation is bypassed' do
post_tag = create(:post_tag)
duplicate = build(:post_tag, post: post_tag.post, tag: post_tag.tag)
expect { duplicate.save!(validate: false) }
.to raise_error(ActiveRecord::RecordNotUnique)
end
end
describe '#destroy!' do
it 'deletes only the selected pair and its sections and updates the counter' do
post_tag = create(:post_tag)
same_post = create(:post_tag, post: post_tag.post)
same_tag = create(:post_tag, tag: post_tag.tag)
sections = [post_tag, same_post, same_tag].map do |link|
create(:post_tag_section, post: link.post, tag: link.tag,
begin_ms: 1000, end_ms: 2000)
end
expect { post_tag.destroy! }.to change(described_class, :count).by(-1)
.and change(PostTagSection, :count).by(-1)
.and change { post_tag.tag.reload.post_count }.from(2).to(1)
expect(described_class.exists?(post: post_tag.post, tag: post_tag.tag)).to be(false)
expect(same_post.reload).to be_persisted
expect(same_tag.reload).to be_persisted
expect(PostTagSection.all).to contain_exactly(*sections.drop(1))
expect(post_tag.post.reload.tags).to contain_exactly(same_post.tag)
expect(post_tag.tag.reload.posts).to contain_exactly(same_tag.post)
end
it 'allows a removed tag to be added again without restoring old sections' do
post_tag = create(:post_tag)
create(:post_tag_section, post: post_tag.post, tag: post_tag.tag,
begin_ms: 1000, end_ms: 2000)
post_tag.destroy!
replacement = create(:post_tag, post: post_tag.post, tag: post_tag.tag)
expect(replacement.reload.sections).to be_empty
expect(replacement.tag.reload.post_count).to eq(1)
end
end
describe '#sections' do describe '#sections' do
it 'loads the owning post_tag from a section using both keys' do
post_tag = create(:post_tag)
create(:post_tag, post: post_tag.post)
create(:post_tag, tag: post_tag.tag)
section = create(:post_tag_section, post: post_tag.post,
tag: post_tag.tag,
begin_ms: 1000, end_ms: 2000)
expect(section.reload.post_tag).to eq(post_tag)
end
it 'loads sections by post_id and tag_id' do it 'loads sections by post_id and tag_id' do
post_tag = create(:post_tag) post_tag = create(:post_tag)
section = create(:post_tag_section, section = create(:post_tag_section,
@@ -12,18 +80,25 @@ RSpec.describe PostTag, type: :model do
end end
it 'does not load sections for another tag on the same post' do it 'does not load sections for another tag on the same post' do
post = create(:post) post_tag = create(:post_tag)
tag = create(:tag) post = post_tag.post
other_tag = create(:tag) other_tag = create(:tag)
post_tag = create(:post_tag, post:, tag:) own_section = create(:post_tag_section,
post:,
tag: post_tag.tag,
begin_ms: 1000,
end_ms: 2000)
create(:post_tag, post:, tag: other_tag)
create(:post_tag_section, create(:post_tag_section,
post:, post:,
tag: other_tag, tag: other_tag,
begin_ms: 1000, begin_ms: 1000,
end_ms: 2000) end_ms: 2000)
expect(post_tag.sections).to be_empty expect(post_tag.reload.sections).to contain_exactly(own_section)
end end
it 'allows open-ended sections' do it 'allows open-ended sections' do
+1
ファイルの表示
@@ -19,6 +19,7 @@ RSpec.describe PostVersion, type: :model do
url: post_record.url, url: post_record.url,
thumbnail_base: post_record.thumbnail_base, thumbnail_base: post_record.thumbnail_base,
tags: post_record.snapshot_tag_names.join(' '), tags: post_record.snapshot_tag_names.join(' '),
tags_json: post_record.snapshot_tags_json,
parent_post_ids: post_record.snapshot_parent_post_ids.join(' '), parent_post_ids: post_record.snapshot_parent_post_ids.join(' '),
original_created_from: post_record.original_created_from, original_created_from: post_record.original_created_from,
original_created_before: post_record.original_created_before, original_created_before: post_record.original_created_before,
+28 -6
ファイルの表示
@@ -57,7 +57,7 @@ RSpec.describe TagNameSanitisationRule, type: :model do
it 'deletes the source tag_name' do it 'deletes the source tag_name' do
described_class.apply! described_class.apply!
expect(TagName.exists?(source.id)).to be(false) expect(TagName.unscoped.exists?(source.id)).to be(false)
expect(existing.reload.name).to eq('foobar') expect(existing.reload.name).to eq('foobar')
end end
end end
@@ -75,7 +75,27 @@ RSpec.describe TagNameSanitisationRule, type: :model do
described_class.apply! described_class.apply!
expected_tag_name_id = existing.canonical_id || existing.id expected_tag_name_id = existing.canonical_id || existing.id
expect(source_tag.reload.tag_name_id).to eq(expected_tag_name_id) expect(source_tag.reload.tag_name_id).to eq(expected_tag_name_id)
expect(TagName.exists?(source_tag_name_id)).to be(false) expect(TagName.unscoped.exists?(source_tag_name_id)).to be(false)
end
end
context 'when the sanitised name is an alias of an existing tag' do
let!(:existing_tag) { create(:tag) }
let!(:alias_name) do
TagName.create!(name: 'foobar', canonical: existing_tag.tag_name)
end
let!(:source) do
TagName.create!(name: 'tmp').tap do |tn|
tn.update_columns(name: 'foo_bar', updated_at: Time.current)
end
end
it 'deletes only the source and preserves the alias and its canonical tag' do
described_class.apply!
expect(TagName.unscoped.exists?(source.id)).to be(false)
expect(alias_name.reload.canonical).to eq(existing_tag.tag_name)
expect(Tag.find(existing_tag.id)).to eq(existing_tag)
end end
end end
@@ -92,13 +112,15 @@ RSpec.describe TagNameSanitisationRule, type: :model do
end end
it 'merges the source tag into the existing tag and deletes the source tag_name' do it 'merges the source tag into the existing tag and deletes the source tag_name' do
expect(TagName.find_by(name: 'foobar')&.tag&.id).to eq(existing_tag.id) post = create(:post)
expect(TagName.find_by(name: 'foo_bar')&.tag&.id).to eq(source_tag.id) PostTag.create!(post:, tag: source_tag)
described_class.apply! described_class.apply!
expect(Tag.exists?(source_tag.id)).to be(false) expect(Tag.unscoped.exists?(source_tag.id)).to be(false)
expect(TagName.exists?(source_tag.tag_name_id)).to be(false) expect(TagName.unscoped.exists?(source_tag_name_id)).to be(false)
expect(post.reload.tags).to contain_exactly(existing_tag)
expect(existing_tag.reload.name).to eq('foobar')
end end
end end
end end
+186 -48
ファイルの表示
@@ -173,6 +173,47 @@ RSpec.describe Tag, type: :model do
end end
end end
describe '.find_or_create_by_tag_name!' do
it 'creates a tag and name with the requested category after stripping whitespace' do
tag = nil
expect {
tag = described_class.find_or_create_by_tag_name!(
' lookup_new ', category: :character)
}.to change(Tag, :count).by(1).and change(TagName, :count).by(1)
expect(tag.name).to eq('lookup_new')
expect(tag.category).to eq('character')
end
it 'reuses the canonical tag for an alias without changing its category' do
tag = create(:tag, category: :character)
alias_name = TagName.create!(name: 'lookup_alias', canonical: tag.tag_name)
found = nil
expect {
found = described_class.find_or_create_by_tag_name!(
alias_name.name, category: :general)
}.to change(Tag, :count).by(0).and change(TagName, :count).by(0)
expect(found).to eq(tag)
expect(found.category).to eq('character')
end
it 'creates a tag for an existing canonical name reached through an alias' do
canonical = create(:tag_name)
alias_name = TagName.create!(name: 'lookup_alias', canonical:)
tag = nil
expect {
tag = described_class.find_or_create_by_tag_name!(
alias_name.name, category: :general)
}.to change(Tag, :count).by(1).and change(TagName, :count).by(0)
expect(tag.tag_name).to eq(canonical)
end
end
describe '.merge_tags!' do describe '.merge_tags!' do
let!(:target_tag) { create(:tag, category: :general) } let!(:target_tag) { create(:tag, category: :general) }
let!(:source_tag) { create(:tag, category: :general) } let!(:source_tag) { create(:tag, category: :general) }
@@ -185,18 +226,14 @@ RSpec.describe Tag, type: :model do
context 'when merging a simple source tag' do context 'when merging a simple source tag' do
let!(:source_post_tag) { PostTag.create!(post: post_record, tag: source_tag) } let!(:source_post_tag) { PostTag.create!(post: post_record, tag: source_tag) }
it 'discards the source post_tag, creates an active target post_tag, discards the source tag, and aliases the source tag_name' do it 'deletes the source tag, moves its post link, and keeps its name as an alias' do
described_class.merge_tags!(target_tag, [source_tag]) described_class.merge_tags!(target_tag, [source_tag])
source_pt = PostTag.with_discarded.find(source_post_tag.id) target_link = PostTag.find_by(post: post_record, tag: target_tag)
active_target = PostTag.kept.find_by(post_id: post_record.id, tag_id: target_tag.id)
expect(source_pt.discarded_at).to be_present expect(PostTag.exists?(post: post_record, tag: source_tag)).to be(false)
expect(source_pt.tag_id).to eq(source_tag.id) expect(target_link).to be_present
expect(active_target).to be_present expect(Tag.unscoped.exists?(source_tag.id)).to be(false)
expect(Tag.with_discarded.find(source_tag.id)).to be_discarded
expect(TagName.with_discarded.find(source_tag_name.id)).not_to be_discarded
expect(source_tag_name.reload.canonical_id).to eq(target_tag.tag_name_id) expect(source_tag_name.reload.canonical_id).to eq(target_tag.tag_name_id)
expect(target_tag.reload.post_count).to eq(1) expect(target_tag.reload.post_count).to eq(1)
end end
@@ -206,38 +243,101 @@ RSpec.describe Tag, type: :model do
let!(:target_post_tag) { PostTag.create!(post: post_record, tag: target_tag) } let!(:target_post_tag) { PostTag.create!(post: post_record, tag: target_tag) }
let!(:source_post_tag) { PostTag.create!(post: post_record, tag: source_tag) } let!(:source_post_tag) { PostTag.create!(post: post_record, tag: source_tag) }
it 'discards the source post_tag, keeps one active target post_tag, discards the source tag, and aliases the source tag_name' do it 'deletes the source link and preserves the existing target link' do
create(:post_tag_section, post: post_record, tag: source_tag,
begin_ms: 1000, end_ms: 2000)
target_section = create(:post_tag_section, post: post_record,
tag: target_tag,
begin_ms: 3000, end_ms: nil)
described_class.merge_tags!(target_tag, [source_tag]) described_class.merge_tags!(target_tag, [source_tag])
source_pt = PostTag.with_discarded.find(source_post_tag.id) target_links = PostTag.where(post: post_record, tag: target_tag)
active = PostTag.kept.where(post_id: post_record.id, tag_id: target_tag.id)
expect(source_pt.discarded_at).to be_present expect(PostTag.exists?(post: post_record, tag: source_tag)).to be(false)
expect(source_pt.tag_id).to eq(source_tag.id) expect(target_links).to contain_exactly(target_post_tag)
expect(active.count).to eq(1) expect(PostTagSection.where(post: post_record, tag: source_tag)).to be_empty
expect(active.first.id).to eq(target_post_tag.id) expect(target_post_tag.reload.sections).to contain_exactly(target_section)
expect(Tag.with_discarded.find(source_tag.id)).to be_discarded expect(Tag.unscoped.exists?(source_tag.id)).to be(false)
expect(TagName.with_discarded.find(source_tag_name.id)).not_to be_discarded
expect(source_tag_name.reload.canonical_id).to eq(target_tag.tag_name_id) expect(source_tag_name.reload.canonical_id).to eq(target_tag.tag_name_id)
expect(target_tag.reload.post_count).to eq(1) expect(target_tag.reload.post_count).to eq(1)
end end
end end
it 'keeps source history and records the new target alias after deleting the source' do
user = create_member_user!
source_name = source_tag.name
source_alias = TagName.create!(name: 'merge_alias', canonical: source_tag_name)
TagVersioning.ensure_snapshot!(source_tag, created_by_user: user)
original_version = source_tag.tag_versions.first
described_class.merge_tags!(target_tag, [source_tag], created_by_user: user)
versions = TagVersion.where(tag_id: source_tag.id).order(:version_no)
expect(versions.pluck(:version_no, :event_type))
.to eq([[1, 'create'], [2, 'discard']])
expect(versions.first).to eq(original_version)
expect(versions.last).to have_attributes(
name: source_name, aliases: source_alias.name, created_by_user: user)
expect(Tag.unscoped.exists?(source_tag.id)).to be(false)
target_versions = target_tag.tag_versions.order(:version_no)
expect(target_versions.pluck(:event_type)).to eq(['create', 'update'])
expect(target_versions.last.aliases.split).to eq([source_name])
end
it 'deletes source relationships while preserving unrelated relationships' do
parent = create(:tag)
child = create(:tag)
nico_tag = create(:tag, :nico)
TagImplication.create!(tag: source_tag, parent_tag: parent)
TagImplication.create!(tag: child, parent_tag: source_tag)
kept_implication = TagImplication.create!(tag: target_tag, parent_tag: parent)
NicoTagRelation.create!(tag: source_tag, nico_tag:)
kept_relation = NicoTagRelation.create!(tag: target_tag, nico_tag:)
TagSimilarity.create!(tag: source_tag, target_tag:, cos: 0.5)
TagSimilarity.create!(tag: target_tag, target_tag: source_tag, cos: 0.5)
kept_similarity = TagSimilarity.create!(tag: target_tag, target_tag: parent,
cos: 0.5)
described_class.merge_tags!(target_tag, [source_tag])
expect(TagImplication.all).to contain_exactly(kept_implication)
expect(NicoTagRelation.all).to contain_exactly(kept_relation)
expect(TagSimilarity.all).to contain_exactly(kept_similarity)
expect(TagVersion.where(tag_id: source_tag.id).order(:version_no).last.parent_tag_ids)
.to eq(parent.id.to_s)
end
it 'preserves material history referencing the deleted source tag' do
source_tag.update!(category: :material)
target_tag.update!(category: :material)
material = Material.create!(tag: source_tag, url: 'https://example.com/material')
version = MaterialVersionRecorder.record!(
material:, event_type: :create, created_by_user: nil)
material.update!(tag: target_tag)
described_class.merge_tags!(target_tag, [source_tag])
expect(version.reload).to have_attributes(
tag_id: source_tag.id, tag_name: source_tag_name.name, tag_category: 'material')
expect(Tag.unscoped.exists?(source_tag.id)).to be(false)
expect(material.reload.tag).to eq(target_tag)
end
context 'when source_tags includes the target itself' do context 'when source_tags includes the target itself' do
let!(:source_post_tag) { PostTag.create!(post: post_record, tag: source_tag) } let!(:source_post_tag) { PostTag.create!(post: post_record, tag: source_tag) }
it 'ignores the target in source_tags while still merging the source tag' do it 'ignores the target in source_tags while still merging the source tag' do
described_class.merge_tags!(target_tag, [source_tag, target_tag]) described_class.merge_tags!(target_tag, [source_tag, target_tag])
source_pt = PostTag.with_discarded.find(source_post_tag.id) target_link = PostTag.find_by(post: post_record, tag: target_tag)
active_target = PostTag.kept.find_by(post_id: post_record.id, tag_id: target_tag.id)
expect(Tag.find(target_tag.id)).to be_present expect(Tag.find(target_tag.id)).to be_present
expect(Tag.with_discarded.find(source_tag.id)).to be_discarded expect(Tag.unscoped.exists?(source_tag.id)).to be(false)
expect(source_pt.discarded_at).to be_present expect(PostTag.exists?(post: post_record, tag: source_tag)).to be(false)
expect(source_pt.tag_id).to eq(source_tag.id) expect(target_link).to be_present
expect(active_target).to be_present
expect(source_tag_name.reload.canonical_id).to eq(target_tag.tag_name_id) expect(source_tag_name.reload.canonical_id).to eq(target_tag.tag_name_id)
expect(target_tag.reload.post_count).to eq(1) expect(target_tag.reload.post_count).to eq(1)
end end
@@ -260,18 +360,16 @@ RSpec.describe Tag, type: :model do
) )
end end
it 'still merges, but discards the source tag_name instead of aliasing it' do it 'still merges and keeps the source name as an alias without validating it' do
described_class.merge_tags!(target_tag, [source_tag]) described_class.merge_tags!(target_tag, [source_tag])
source_pt = PostTag.with_discarded.find(source_post_tag.id) target_link = PostTag.find_by(post: post_record, tag: target_tag)
active_target = PostTag.kept.find_by(post_id: post_record.id, tag_id: target_tag.id)
discarded_source_tag_name = TagName.with_discarded.find(source_tag_name.id)
expect(source_pt.discarded_at).to be_present expect(PostTag.exists?(post: post_record, tag: source_tag)).to be(false)
expect(source_pt.tag_id).to eq(source_tag.id) expect(target_link).to be_present
expect(active_target).to be_present
expect(Tag.with_discarded.find(source_tag.id)).to be_discarded expect(Tag.unscoped.exists?(source_tag.id)).to be(false)
expect(source_tag_name.reload.canonical_id).to eq(target_tag.tag_name_id)
expect(target_tag.reload.post_count).to eq(1) expect(target_tag.reload.post_count).to eq(1)
end end
end end
@@ -288,36 +386,72 @@ RSpec.describe Tag, type: :model do
message: 'init') message: 'init')
end end
it 'rolls back the transaction' do it 'rolls back earlier deletions, links, and history when a later source has a wiki' do
earlier_source = create(:tag)
earlier_name = earlier_source.tag_name
source_section = create(:post_tag_section, post: post_record,
tag: source_tag,
begin_ms: 1000, end_ms: 2000)
expect { expect {
described_class.merge_tags!(target_tag, [source_tag]) described_class.merge_tags!(target_tag, [earlier_source, source_tag])
}.to raise_error(ActiveRecord::RecordInvalid) }.to raise_error(ActiveRecord::RecordInvalid)
expect(Tag.with_discarded.find(source_tag.id)).not_to be_discarded expect(Tag.unscoped.exists?(earlier_source.id)).to be(true)
expect(TagName.with_discarded.find(source_tag_name.id)).not_to be_discarded expect(earlier_name.reload.canonical_id).to be_nil
expect(PostTag.kept.find(source_post_tag.id).tag_id).to eq(source_tag.id) expect(TagVersion.where(tag_id: [earlier_source.id, source_tag.id, target_tag.id]))
expect(PostTag.kept.find_by(post_id: post_record.id, tag_id: target_tag.id)).to be_nil .to be_empty
expect(Tag.unscoped.exists?(source_tag.id)).to be(true)
expect(TagName.unscoped.exists?(source_tag_name.id)).to be(true)
expect(source_post_tag.reload.tag_id).to eq(source_tag.id)
expect(source_post_tag.sections).to contain_exactly(source_section)
expect(PostTag.find_by(post: post_record, tag: target_tag)).to be_nil
expect(source_tag.reload.post_count).to eq(1)
expect(source_tag_name.reload.canonical_id).to be_nil expect(source_tag_name.reload.canonical_id).to be_nil
expect(target_tag.reload.post_count).to eq(0) expect(target_tag.reload.post_count).to eq(0)
end end
end end
context 'when merging a nico source tag' do context 'when merging a nico source tag' do
let!(:target_tag) { create(:tag, category: :nico, name: 'nico:foo') } let!(:target_tag) do
let!(:source_tag) { create(:tag, category: :nico, name: 'nico:bar') } create(:tag, category: :nico, tag_name: create(:tag_name, name: 'nico:foo'))
end
let!(:source_tag) do
create(:tag, category: :nico, tag_name: create(:tag_name, name: 'nico:bar'))
end
let!(:source_tag_name_id) { source_tag.tag_name_id } let!(:source_tag_name_id) { source_tag.tag_name_id }
it 'discards the source tag_name instead of aliasing it' do it 'deletes the source tag and name instead of keeping an alias' do
described_class.merge_tags!(target_tag, [source_tag]) described_class.merge_tags!(target_tag, [source_tag])
discarded_source_tag = Tag.with_discarded.find(source_tag.id) expect(Tag.unscoped.exists?(source_tag.id)).to be(false)
discarded_source_tag_name = TagName.with_discarded.find(source_tag_name_id) expect(TagName.unscoped.exists?(source_tag_name_id)).to be(false)
expect(discarded_source_tag).to be_discarded
expect(discarded_source_tag_name).to be_discarded
expect(discarded_source_tag_name.canonical_id).to be_nil
expect(target_tag.reload.post_count).to eq(0) expect(target_tag.reload.post_count).to eq(0)
end end
it 'keeps nico history while deleting source links and allows recreating the name' do
linked_tag = create(:tag)
NicoTagRelation.create!(nico_tag: source_tag, tag: linked_tag)
kept_relation = NicoTagRelation.create!(nico_tag: target_tag, tag: linked_tag)
user = create_member_user!
source_name = source_tag.name
described_class.merge_tags!(target_tag, [source_tag], created_by_user: user)
expect(NicoTagRelation.all).to contain_exactly(kept_relation)
versions = NicoTagVersion.where(tag_id: source_tag.id).order(:version_no)
expect(versions.pluck(:version_no, :event_type))
.to eq([[1, 'create'], [2, 'discard']])
expect(versions.last).to have_attributes(
name: source_name, linked_tags: linked_tag.name, created_by_user: user)
recreated = described_class.find_or_create_by_tag_name!(source_name, category: :nico)
expect(recreated.id).not_to eq(source_tag.id)
expect(recreated.tag_name_id).not_to eq(source_tag_name_id)
expect(recreated.nico_tag_versions).to be_empty
expect(versions.reload.size).to eq(2)
end
end end
def snapshot_tags(post) def snapshot_tags(post)
@@ -333,6 +467,7 @@ RSpec.describe Tag, type: :model do
url: post.url, url: post.url,
thumbnail_base: post.thumbnail_base, thumbnail_base: post.thumbnail_base,
tags: snapshot_tags(post), tags: snapshot_tags(post),
tags_json: post.snapshot_tags_json,
parent_post_ids: post.snapshot_parent_post_ids.join(' '), parent_post_ids: post.snapshot_parent_post_ids.join(' '),
original_created_from: post.original_created_from, original_created_from: post.original_created_from,
original_created_before: post.original_created_before, original_created_before: post.original_created_before,
@@ -364,12 +499,15 @@ RSpec.describe Tag, type: :model do
expect(latest.event_type).to eq('update') expect(latest.event_type).to eq('update')
expect(latest.created_by_user).to be_nil expect(latest.created_by_user).to be_nil
expect(latest.tags).to eq(snapshot_tags(post_record.reload)) expect(latest.tags).to eq(snapshot_tags(post_record.reload))
expect(latest.tags_json.map { |item| item.fetch('id') }).to eq([target_tag.id])
expect(affected_versions.first.tags_json.map { |item| item.fetch('id') })
.to eq([source_tag.id])
expect(unaffected_post.reload.post_versions.count).to eq(1) expect(unaffected_post.reload.post_versions.count).to eq(1)
end end
end end
context 'when the source tag has no active post_tags' do context 'when the source tag has no post_tags' do
let!(:another_post) do let!(:another_post) do
Post.create!(url: 'https://example.com/posts/3', title: 'another post') Post.create!(url: 'https://example.com/posts/3', title: 'another post')
end end
-184
ファイルの表示
@@ -1,184 +0,0 @@
require 'rails_helper'
RSpec.describe 'Post imports API', type: :request do
let(:member) { create(:user, :member) }
before do
allow(Preview::UrlSafety).to receive(:validate) do |url|
[URI.parse(url), ['8.8.8.8']]
end
allow(PostMetadataFetcher).to receive(:fetch).and_return(
title: 'fetched title',
thumbnail_base: nil,
tags: ''
)
end
describe 'POST /posts/import/preview' do
it 'requires a member' do
sign_out
post '/posts/import/preview', params: { source: 'https://example.com/post' }
expect(response).to have_http_status(:unauthorized)
sign_in_as(create(:user, :guest))
post '/posts/import/preview', params: { source: 'https://example.com/post' }
expect(response).to have_http_status(:forbidden)
end
it 'parses a URL list and returns preview rows' do
sign_in_as(member)
post '/posts/import/preview', params: {
source: " https://example.com/one \r\n\r\nhttps://example.com/two"
}
expect(response).to have_http_status(:ok)
expect(json.fetch('rows').map { _1.fetch('source_row') }).to eq([1, 3])
expect(json.fetch('rows').map { _1.fetch('url') }).to eq([
'https://example.com/one',
'https://example.com/two'
])
end
it 'returns a safe 400 response for an invalid source' do
sign_in_as(member)
post '/posts/import/preview', params: { source: '' }
expect(response).to have_http_status(:bad_request)
expect(json.fetch('message')).to eq('URL を入力してください.')
end
end
describe 'POST /posts/import/validate' do
it 'accepts camel-case row properties and returns their warnings' do
sign_in_as(member)
post '/posts/import/validate', params: {
rows: [{
sourceRow: '1',
url: 'https://example.com/post',
metadataUrl: 'https://example.com/post',
attributes: { title: 'manual title' },
provenance: { url: 'manual', title: 'manual' },
tagSources: { automatic: '', manual: '' },
fieldWarnings: { title: ['old warning'] },
baseWarnings: ['base warning']
}],
changed_row: -1
}
expect(response).to have_http_status(:ok)
result = json.fetch('rows').first
expect(result.fetch('source_row')).to eq(1)
expect(result.fetch('tag_sources')).to eq('automatic' => '', 'manual' => '')
expect(result.fetch('field_warnings')).to eq('title' => ['old warning'])
expect(result.fetch('base_warnings')).to eq(['base warning'])
end
it 'rejects a non-array rows value with 400' do
sign_in_as(member)
post '/posts/import/validate', params: { rows: { sourceRow: 1 }, changed_row: -1 }
expect(response).to have_http_status(:bad_request)
expect(json.fetch('message')).to eq('取込行の形式が不正です.')
end
it 'returns original created datetime validation errors for minute precision' do
sign_in_as(member)
post '/posts/import/validate', params: {
rows: [{
sourceRow: 1,
url: 'https://example.com/post',
metadataUrl: 'https://example.com/post',
attributes: {
originalCreatedFrom: '2020-01-01T00:00:30Z',
originalCreatedBefore: '2020-01-01T00:01Z' },
provenance: {
url: 'manual',
originalCreatedFrom: 'manual',
originalCreatedBefore: 'manual' },
tagSources: { automatic: '', manual: '' }
}],
changed_row: -1
}
expect(response).to have_http_status(:ok)
expect(json.fetch('rows').first.fetch('validation_errors')).to include(
'original_created_from' => ['オリジナルの作成日時は分単位で入力してください.']
)
expect(json.fetch('rows').first.fetch('validation_errors'))
.not_to have_key('original_created_at')
end
end
describe 'POST /posts/import' do
it 'keeps the duration string contract through preview, validate, and import' do
sign_in_as(member)
allow(PostMetadataFetcher).to receive(:fetch).and_return(
title: 'fetched title',
thumbnail_base: nil,
duration: '2.5',
tags: '動画'
)
post '/posts/import/preview', params: {
source: 'https://example.com/video'
}
preview_row = json.fetch('rows').first
expect(preview_row.dig('attributes', 'duration')).to eq('2.5')
post '/posts/import/validate', params: {
rows: [{
sourceRow: preview_row.fetch('source_row'),
url: preview_row.fetch('url'),
attributes: preview_row.fetch('attributes'),
provenance: preview_row.fetch('provenance'),
tagSources: preview_row.fetch('tag_sources'),
metadataUrl: preview_row.fetch('metadata_url')
}],
changed_row: -1
}
validated_row = json.fetch('rows').first
expect(validated_row.dig('attributes', 'duration')).to eq('2.5')
post '/posts/import', params: {
rows: [{
sourceRow: validated_row.fetch('source_row'),
url: validated_row.fetch('url'),
attributes: validated_row.fetch('attributes'),
provenance: validated_row.fetch('provenance'),
tagSources: validated_row.fetch('tag_sources'),
metadataUrl: validated_row.fetch('metadata_url')
}]
}
expect(response).to have_http_status(:ok)
expect(Post.order(:id).last.video_ms).to eq(2_500)
end
it 'returns a formal skipped result for an existing post' do
existing = create(:post, url: 'https://example.com/existing')
sign_in_as(member)
post '/posts/import', params: {
rows: [{
sourceRow: 1,
url: existing.url,
attributes: { title: 'ignored' },
provenance: { url: 'manual', title: 'manual' },
tagSources: { automatic: '', manual: '' }
}]
}
expect(response).to have_http_status(:ok)
expect(json).to include('created' => 0, 'skipped' => 1, 'failed' => 0)
expect(json.fetch('rows').first).to include(
'status' => 'skipped',
'existing_post_id' => existing.id
)
end
end
end
+321 -147
ファイルの表示
@@ -9,11 +9,11 @@ RSpec.describe 'Posts API', type: :request do
# resized_thumbnail! が MiniMagick 依存でコケやすいので request spec ではスタブしとくのが無難。 # resized_thumbnail! が MiniMagick 依存でコケやすいので request spec ではスタブしとくのが無難。
before do before do
allow_any_instance_of(Post).to receive(:resized_thumbnail!).and_return(true) allow_any_instance_of(Post).to receive(:resized_thumbnail!).and_return(true)
allow(Post).to receive(:resized_thumbnail_attachment).and_return( allow(Post).to receive(:resized_thumbnail_attachment) do
io: StringIO.new('dummy'), { io: StringIO.new('dummy'),
filename: 'resized_thumbnail.jpg', filename: 'resized_thumbnail.jpg',
content_type: 'image/jpeg' content_type: 'image/jpeg' }
) end
end end
def create_nico_tag!(name) def create_nico_tag!(name)
@@ -21,8 +21,7 @@ RSpec.describe 'Posts API', type: :request do
end end
def dummy_upload def dummy_upload
# 中身は何でもいい(加工処理はスタブしてる) real_thumbnail_upload
Rack::Test::UploadedFile.new(StringIO.new('dummy'), 'image/jpeg', original_filename: 'dummy.jpg')
end end
def real_thumbnail_upload def real_thumbnail_upload
@@ -56,6 +55,7 @@ RSpec.describe 'Posts API', type: :request do
thumbnail_base: post.thumbnail_base, thumbnail_base: post.thumbnail_base,
video_ms: post.video_ms, video_ms: post.video_ms,
tags: post.snapshot_tag_names.join(' '), tags: post.snapshot_tag_names.join(' '),
tags_json: post.snapshot_tags_json,
parent_post_ids: post.snapshot_parent_post_ids.join(' '), parent_post_ids: post.snapshot_parent_post_ids.join(' '),
original_created_from: post.original_created_from, original_created_from: post.original_created_from,
original_created_before: post.original_created_before, original_created_before: post.original_created_before,
@@ -102,34 +102,34 @@ RSpec.describe 'Posts API', type: :request do
end end
end end
describe "GET /posts" do describe 'GET /posts' do
let!(:user) { create_member_user! } let!(:user) { create_member_user! }
let!(:tag_name) { TagName.create!(name: "spec_tag") } let!(:tag_name) { TagName.create!(name: 'spec_tag') }
let!(:tag) { Tag.create!(tag_name:, category: :general) } let!(:tag) { Tag.create!(tag_name:, category: :general) }
let!(:tag_name2) { TagName.create!(name: 'unko') } let!(:tag_name2) { TagName.create!(name: 'unko') }
let!(:tag2) { Tag.create!(tag_name: tag_name2, category: :deerjikist) } let!(:tag2) { Tag.create!(tag_name: tag_name2, category: :deerjikist) }
let!(:alias_tag_name) { TagName.create!(name: 'manko', canonical: tag_name) } let!(:alias_tag_name) { TagName.create!(name: 'manko', canonical: tag_name) }
let!(:hit_post) do let!(:hit_post) do
Post.create!(uploaded_user: user, title: "hello spec world", Post.create!(uploaded_user: user, title: 'hello spec world',
url: 'https://example.com/spec2').tap do |p| url: 'https://example.com/spec2').tap do |p|
PostTag.create!(post: p, tag:) PostTag.create!(post: p, tag:)
end end
end end
let!(:miss_post) do let!(:miss_post) do
Post.create!(uploaded_user: user, title: "unrelated title", Post.create!(uploaded_user: user, title: 'unrelated title',
url: 'https://example.com/spec3').tap do |p| url: 'https://example.com/spec3').tap do |p|
PostTag.create!(post: p, tag: tag2) PostTag.create!(post: p, tag: tag2)
end end
end end
it "returns posts with tag name in JSON" do it 'returns posts with tag name in JSON' do
get "/posts" get '/posts'
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
posts = json.fetch("posts") posts = json.fetch('posts')
# 全postの全tagが name を含むこと # 全postの全tagが name を含むこと
expect(posts).not_to be_empty expect(posts).not_to be_empty
@@ -142,18 +142,21 @@ RSpec.describe 'Posts API', type: :request do
expect(json['count']).to be_an(Integer) expect(json['count']).to be_an(Integer)
# spec_tag を含む投稿が存在すること # spec_tag を含む投稿が存在すること
all_tag_names = posts.flat_map { |p| p["tags"].map { |t| t["name"] } } all_tag_names = posts.flat_map { |p| p['tags'].map { |t| t['name'] } }
expect(all_tag_names).to include("spec_tag") expect(all_tag_names).to include('spec_tag')
end end
it 'keeps children and sections keys in non-detail tag responses' do it 'keeps children and sections keys in non-detail tag responses' do
PostTagSection.create!(post: hit_post, tag:, begin_ms: 1_000, end_ms: nil) PostTagSection.create!(post: hit_post, tag:, begin_ms: 1_000, end_ms: nil)
deprecated_tag = create(:tag, deprecated_at: Time.current)
create(:post_tag, post: hit_post, tag: deprecated_tag)
get '/posts' get '/posts'
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
hit_json = json.fetch('posts').find { |post| post['id'] == hit_post.id } hit_json = json.fetch('posts').find { |post| post['id'] == hit_post.id }
expect(hit_json.fetch('tags').map { |item| item.fetch('id') }).to eq([tag.id])
tag_json = hit_json.fetch('tags').find { |item| item['name'] == 'spec_tag' } tag_json = hit_json.fetch('tags').find { |item| item['name'] == 'spec_tag' }
expect(tag_json.fetch('children')).to eq([]) expect(tag_json.fetch('children')).to eq([])
@@ -162,9 +165,29 @@ RSpec.describe 'Posts API', type: :request do
]) ])
end end
context "when q is provided" do it 'preloads tag details and sections as the number of posts grows' do
it "filters posts by q (hit case)" do 5.times do
get "/posts", params: { tags: "spec_tag" } link = create(:post_tag, post: create(:post, uploaded_user: user))
create(:post_tag_section, post: link.post, tag: link.tag,
begin_ms: 1000, end_ms: 2000)
end
get '/posts', params: { limit: 1 }
one_post_queries = count_sql_queries do
get '/posts', params: { limit: 1 }
end
many_post_queries = count_sql_queries do
get '/posts', params: { limit: 20 }
end
expect(response).to have_http_status(:ok)
expect(json.fetch('posts').size).to eq(8)
expect(many_post_queries).to be <= one_post_queries
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) expect(response).to have_http_status(:ok)
posts = json.fetch('posts') posts = json.fetch('posts')
@@ -182,8 +205,8 @@ RSpec.describe 'Posts API', type: :request do
end end
end end
it "filters posts by q (hit case by alias)" do it 'filters posts by q (hit case by alias)' do
get "/posts", params: { tags: "manko" } get '/posts', params: { tags: 'manko' }
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
posts = json.fetch('posts') posts = json.fetch('posts')
@@ -201,11 +224,11 @@ RSpec.describe 'Posts API', type: :request do
end end
end end
it "returns empty posts when nothing matches" do it 'returns empty posts when nothing matches' do
get "/posts", params: { tags: "no_such_keyword_12345" } get '/posts', params: { tags: 'no_such_keyword_12345' }
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
expect(json.fetch("posts")).to eq([]) expect(json.fetch('posts')).to eq([])
expect(json.fetch('count')).to eq(0) expect(json.fetch('count')).to eq(0)
end end
end end
@@ -460,6 +483,71 @@ RSpec.describe 'Posts API', type: :request do
end end
end end
context 'when update times include version history' do
let(:t0) { Time.zone.parse('2020-01-01 12:00:00') }
let(:t1) { t0 + 1.day }
let(:t2) { t0 + 2.days }
let(:t3) { t0 + 3.days }
let!(:history_post) do
create(:post, url: 'https://example.com/version-time/history',
created_at: t0, updated_at: t0)
end
let!(:plain_post) do
create(:post, url: 'https://example.com/version-time/plain',
created_at: t1, updated_at: t1)
end
let!(:newer_post) do
create(:post, url: 'https://example.com/version-time/newer',
created_at: t0, updated_at: t3)
end
before do
link = create(:post_tag, post: history_post, tag:)
travel_to(t0) do
PostVersionRecorder.record!(post: history_post,
event_type: :create, created_by_user: nil)
PostVersionRecorder.record!(post: newer_post,
event_type: :create, created_by_user: nil)
end
travel_to(t2) do
link.destroy!
PostVersionRecorder.record!(post: history_post,
event_type: :update, created_by_user: nil)
end
create(:post_tag, post: plain_post, tag:, created_at: t3)
end
['asc', 'desc'].each do |direction|
it "sorts by the later of post update and latest version time (#{ direction })" do
get '/posts', params: { url: '/version-time/', order: "updated_at:#{ direction }" }
expect(response).to have_http_status(:ok)
expected_ids = [plain_post.id, history_post.id, newer_post.id]
expected_ids.reverse! if direction == 'desc'
expect(json.fetch('posts').map { |item| item.fetch('id') }).to eq(expected_ids)
expect(json.fetch('count')).to eq(3)
times = json.fetch('posts').to_h do |item|
[item.fetch('id'), Time.zone.parse(item.fetch('updated_at'))]
end
expect(times).to eq({ plain_post.id => t1,
history_post.id => t2,
newer_post.id => t3 })
expect(history_post.reload.updated_at).to eq(t0)
end
end
it 'filters inclusively by the latest version time after a tag is deleted' do
get '/posts', params: { url: '/version-time/',
updated_from: t2.iso8601,
updated_to: t2.iso8601 }
expect(response).to have_http_status(:ok)
expect(json.fetch('posts').map { |item| item.fetch('id') }).to eq([history_post.id])
expect(json.fetch('count')).to eq(1)
end
end
context 'when original_created_from/original_created_to are provided' do context 'when original_created_from/original_created_to are provided' do
# 注意: controller の現状ロジックに合わせてる # 注意: controller の現状ロジックに合わせてる
# original_created_from は `original_created_before > ?` # original_created_from は `original_created_before > ?`
@@ -693,6 +781,73 @@ RSpec.describe 'Posts API', type: :request do
end end
end end
describe 'GET /posts/metadata' do
let(:member) { create(:user, :member) }
it 'returns compact existing post data without fetching external metadata' do
sign_in_as(member)
existing = create(
:post,
title: 'existing post',
url: 'https://example.com/existing')
existing.thumbnail.attach(
io: StringIO.new('thumbnail'),
filename: 'thumbnail.jpg',
content_type: 'image/jpeg')
expect(PostMetadataFetcher).not_to receive(:fetch)
get '/posts/metadata', params: { url: 'https://example.com/existing' }
expect(response).to have_http_status(:ok)
expect(json).to include(
'url' => existing.url,
'existing_post_id' => existing.id,
'field_warnings' => { })
expect(json.fetch('existing_post')).to include(
'id' => existing.id,
'title' => existing.title,
'url' => existing.url,
'thumbnail_base' => existing.thumbnail_base)
expect(json.dig('existing_post', 'thumbnail'))
.to include('/rails/active_storage/blobs/proxy/')
end
it 'returns fetched metadata and structured display tags' do
sign_in_as(member)
allow(Preview::UrlSafety).to receive(:validate)
allow(PostMetadataFetcher).to receive(:fetch).and_return(
title: 'fetched title',
thumbnail_base: 'https://example.com/thumbnail.jpg',
tags: 'character:虹夏',
display_tags: [{ name: '虹夏', category: 'character' }],
original_created_from: nil,
original_created_before: nil,
duration: '1:00',
video_ms: 60_000)
get '/posts/metadata', params: { url: 'https://example.com/new' }
expect(response).to have_http_status(:ok)
expect(json).to include(
'url' => 'https://example.com/new',
'title' => 'fetched title',
'duration' => '1:00',
'video_ms' => 60_000,
'field_warnings' => { })
expect(json.fetch('display_tags')).to eq(
[{ 'name' => '虹夏', 'category' => 'character' }])
end
it 'returns URL validation errors as 422' do
sign_in_as(member)
get '/posts/metadata', params: { url: 'file:///etc/passwd' }
expect(response).to have_http_status(:unprocessable_entity)
expect(json.fetch('errors')).to have_key('url')
end
end
describe 'POST /posts' do describe 'POST /posts' do
let(:member) { create(:user, :member) } let(:member) { create(:user, :member) }
let!(:alias_tag_name) { TagName.create!(name: 'manko', canonical: tag_name) } let!(:alias_tag_name) { TagName.create!(name: 'manko', canonical: tag_name) }
@@ -712,6 +867,46 @@ RSpec.describe 'Posts API', type: :request do
expect(response).to have_http_status(:forbidden) expect(response).to have_http_status(:forbidden)
end end
it 'dry-runs without persisting posts or new tags' do
sign_in_as(member)
counts = [Post.count, Tag.count, TagName.count]
post '/posts?dry=1', params: post_write_params(
title: 'dry-run post',
url: 'https://example.com/dry-run',
tags: 'character:new_dry_run_tag')
expect(response).to have_http_status(:ok)
expect(json).to include(
'url' => 'https://example.com/dry-run',
'tags' => 'new_dry_run_tag',
'existing_post_id' => nil)
expect(json.fetch('display_tags')).to eq(
[{ 'name' => 'new_dry_run_tag',
'category' => 'character',
'section_literals' => [] }])
expect([Post.count, Tag.count, TagName.count]).to eq(counts)
end
it 'dry-runs an existing URL without validating its upload' do
sign_in_as(member)
existing = create(:post, url: 'https://example.com/dry-existing')
invalid_upload = Rack::Test::UploadedFile.new(
StringIO.new('<svg></svg>'),
'image/png',
original_filename: 'thumbnail.png')
post '/posts?dry=1', params: post_write_params(
title: '',
url: existing.url,
tags: '',
thumbnail: invalid_upload)
expect(response).to have_http_status(:ok)
expect(json).to include('existing_post_id' => existing.id)
expect(json.fetch('field_warnings')).not_to have_key('thumbnail_base')
end
it '201 and creates post + tags when member' do it '201 and creates post + tags when member' do
sign_in_as(member) sign_in_as(member)
@@ -769,7 +964,8 @@ RSpec.describe 'Posts API', type: :request do
) )
expect(response).to have_http_status(:created) expect(response).to have_http_status(:created)
expect(open_transactions).to eq([baseline_open_transactions]) expect(open_transactions).to eq(
[baseline_open_transactions, baseline_open_transactions])
end end
it 'returns 422 and does not create a post when thumbnail resize fails' do it 'returns 422 and does not create a post when thumbnail resize fails' do
@@ -827,7 +1023,7 @@ RSpec.describe 'Posts API', type: :request do
expect(response).to have_http_status(:unprocessable_entity) expect(response).to have_http_status(:unprocessable_entity)
expect(json.fetch('errors')).to include( expect(json.fetch('errors')).to include(
'tags' => ['廃止済みタグは付与できません.'] 'tags' => ['廃止済みタグがあります: deprecated_direct_tag']
) )
end end
@@ -1054,11 +1250,9 @@ RSpec.describe 'Posts API', type: :request do
) )
end end
context "when nico tag already exists in tags" do context 'when nico tag already exists in tags' do
before do before do
Tag.find_undiscard_or_create_by!( Tag.find_or_create_by_tag_name!('nico:nico_tag', category: :nico)
tag_name: TagName.find_undiscard_or_create_by!(name: 'nico:nico_tag'),
category: :nico)
end end
it 'returns 422 with tag field errors' do it 'returns 422 with tag field errors' do
@@ -1251,6 +1445,47 @@ RSpec.describe 'Posts API', type: :request do
end end
end end
describe 'POST /posts/bulk' do
let(:member) { create(:user, :member) }
it 'parses the manifest and indexed thumbnail parts' do
sign_in_as(member)
manifest = [{ 'url' => 'https://example.com/bulk',
'title' => 'bulk post',
'tags' => 'spec_tag',
'parent_post_ids' => '' }]
creator = instance_double(
PostBulkCreator,
run: { results: [{ status: 'created', post: { id: 123 } }] })
allow(PostBulkCreator).to receive(:new).and_return(creator)
post '/posts/bulk', params: {
posts: JSON.generate(manifest),
thumbnails: { '0' => real_thumbnail_upload } }
expect(response).to have_http_status(:ok)
expect(json.fetch('results')).to eq(
[{ 'status' => 'created', 'post' => { 'id' => 123 } }])
expect(PostBulkCreator).to have_received(:new) do |arguments|
expect(arguments[:actor]).to eq(member)
expect(arguments[:posts]).to eq(manifest)
expect(arguments[:thumbnails].keys).to eq([0])
expect(arguments[:host]).to eq('http://www.example.com')
end
end
it 'rejects malformed manifests as a request-level error' do
sign_in_as(member)
post '/posts/bulk', params: {
posts: '{',
thumbnails: { '0' => real_thumbnail_upload } }
expect(response).to have_http_status(:bad_request)
expect(json.fetch('message')).to eq('posts manifest の JSON が不正です.')
end
end
describe 'PUT /posts/:id' do describe 'PUT /posts/:id' do
let(:member) { create(:user, :member) } let(:member) { create(:user, :member) }
@@ -1270,9 +1505,11 @@ RSpec.describe 'Posts API', type: :request do
it '200 and updates title + resync tags when member' do it '200 and updates title + resync tags when member' do
sign_in_as(member) sign_in_as(member)
create(:post_tag_section, post: post_record, tag:,
begin_ms: 1000, end_ms: 2000)
tn2 = TagName.create!(name: 'spec_tag_2') tn2 = TagName.create!(name: 'spec_tag_2')
Tag.create!(tag_name: tn2, category: :general) replacement_tag = Tag.create!(tag_name: tn2, category: :general)
put "/posts/#{post_record.id}", params: post_update_params( put "/posts/#{post_record.id}", params: post_update_params(
post_record, post_record,
@@ -1285,6 +1522,38 @@ RSpec.describe 'Posts API', type: :request do
names = json['tags'].map { |n| n['name'] } names = json['tags'].map { |n| n['name'] }
expect(names).to include('spec_tag_2') expect(names).to include('spec_tag_2')
expect(names).not_to include('spec_tag')
expect(PostTag.exists?(post: post_record, tag:)).to be(false)
expect(PostTagSection.exists?(post: post_record, tag:)).to be(false)
expect(tag.reload.post_count).to eq(0)
expect(replacement_tag.reload.post_count).to eq(1)
versions = post_record.post_versions.order(:version_no)
expect(versions.first.tags_json).to include(
a_hash_including('id' => tag.id,
'sections' => [{ 'begin_ms' => 1000, 'end_ms' => 2000 }]))
expect(versions.last.tags_json.map { |item| item.fetch('id') })
.not_to include(tag.id)
end
it 'can add a removed tag again and records both changes' do
sign_in_as(member)
put "/posts/#{ post_record.id }", params: post_update_params(post_record, tags: '')
expect(response).to have_http_status(:ok)
expect(PostTag.exists?(post: post_record, tag:)).to be(false)
put "/posts/#{ post_record.id }", params: post_update_params(
post_record, tags: 'spec_tag')
expect(response).to have_http_status(:ok)
expect(PostTag.where(post: post_record, tag:).count).to eq(1)
expect(PostTag.find_by!(post: post_record, tag:).created_user).to eq(member)
expect(tag.reload.post_count).to eq(1)
snapshots = post_record.post_versions.order(:version_no).map do |version|
version.tags_json.map { |item| item.fetch('id') }
end
expect(snapshots.map { |ids| ids.include?(tag.id) }).to eq([true, false, true])
end end
it 'rejects a deprecated tag specified directly' do it 'rejects a deprecated tag specified directly' do
@@ -1307,11 +1576,9 @@ RSpec.describe 'Posts API', type: :request do
) )
end end
context "when nico tag already exists in tags" do context 'when nico tag already exists in tags' do
before do before do
Tag.find_undiscard_or_create_by!( Tag.find_or_create_by_tag_name!('nico:nico_tag', category: :nico)
tag_name: TagName.find_undiscard_or_create_by!(name: 'nico:nico_tag'),
category: :nico)
end end
it 'returns 422 with tag field errors' do it 'returns 422 with tag field errors' do
@@ -1564,7 +1831,7 @@ RSpec.describe 'Posts API', type: :request do
expect(post_record.reload.title).to eq('updated by other user') expect(post_record.reload.title).to eq('updated by other user')
end end
it 'returns 409 with mergeable true when stale tag changes do not conflict but merge is not requested' do it 'returns mergeable 409 for stale non-conflicting tag changes without merge' do
sign_in_as(member) sign_in_as(member)
base_version = create_post_version_for!(post_record.reload) base_version = create_post_version_for!(post_record.reload)
@@ -1730,123 +1997,29 @@ RSpec.describe 'Posts API', type: :request do
expect(response).to have_http_status(:not_found) expect(response).to have_http_status(:not_found)
end end
it '200 and returns viewed boolean' do it 'returns viewed state and current tags with their sections' do
create(:post_tag_section, post: post_record, tag:,
begin_ms: 1000, end_ms: nil)
deprecated_tag = create(:tag, deprecated_at: Time.current)
create(:post_tag, post: post_record, tag: deprecated_tag)
get '/posts/random' get '/posts/random'
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
expect(json).to have_key('viewed') expect(json).to have_key('viewed')
expect([true, false]).to include(json['viewed']) expect([true, false]).to include(json['viewed'])
expect(json.fetch('tags')).to contain_exactly(
a_hash_including('id' => tag.id,
'children' => [],
'sections' => [{ 'begin_ms' => 1000, 'end_ms' => nil }]))
end end
end end
describe 'GET /posts/changes' do describe 'GET /posts/changes' do
let(:member) { create(:user, :member) } it 'returns 404 for the retired history endpoint' do
get '/posts/changes'
it 'returns add/remove events (history) for a post' do expect(response).to have_http_status(:not_found)
# 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
end end
@@ -1876,6 +2049,7 @@ RSpec.describe 'Posts API', type: :request do
url: post.url, url: post.url,
thumbnail_base: post.thumbnail_base, thumbnail_base: post.thumbnail_base,
tags: snapshot_tags(post), tags: snapshot_tags(post),
tags_json: post.snapshot_tags_json,
parent_post_ids: post.snapshot_parent_post_ids.join(' '), parent_post_ids: post.snapshot_parent_post_ids.join(' '),
original_created_from: post.original_created_from, original_created_from: post.original_created_from,
original_created_before: post.original_created_before, original_created_before: post.original_created_before,
@@ -1897,7 +2071,7 @@ RSpec.describe 'Posts API', type: :request do
end end
let!(:v2) do let!(:v2) do
post_record.post_tags.kept.find_by!(tag: tag).discard_by!(member) post_record.post_tags.find_by!(tag: tag).destroy!
PostTag.create!(post: post_record, tag: tag2, created_user: member) PostTag.create!(post: post_record, tag: tag2, created_user: member)
post_record.update!( post_record.update!(
title: 'updated spec post', title: 'updated spec post',
@@ -2271,9 +2445,9 @@ RSpec.describe 'Posts API', type: :request do
expect(response).to have_http_status(:created) expect(response).to have_http_status(:created)
expect(Time.iso8601(json.fetch('original_created_from'))) expect(Time.iso8601(json.fetch('original_created_from')))
.to eq(Time.iso8601('2020-01-01T00:00Z')) .to eq(Time.utc(2020, 1, 1, 0, 0))
expect(Time.iso8601(json.fetch('original_created_before'))) expect(Time.iso8601(json.fetch('original_created_before')))
.to eq(Time.iso8601('2020-01-01T00:01Z')) .to eq(Time.utc(2020, 1, 1, 0, 1))
end end
it 'rejects unparseable original created timestamps on PUT /posts/:id' do it 'rejects unparseable original created timestamps on PUT /posts/:id' do
+83
ファイルの表示
@@ -226,6 +226,14 @@ RSpec.describe 'Tags deerjikists API', type: :request do
[platform2, code2], [platform2, code2],
) )
end end
it 'locks the tag before replacing the complete list' do
expect_any_instance_of(Tag).to receive(:lock!).and_call_original
do_request
expect(response).to have_http_status(:ok)
end
end end
context 'when tag already has deerjikists' do context 'when tag already has deerjikists' do
@@ -299,6 +307,81 @@ RSpec.describe 'Tags deerjikists API', type: :request do
end end
end end
context 'when platform is outside the enum' do
let(:payload) do
[
{ platform: 'invalid', code: code1 },
]
end
it 'returns 422 with an indexed platform error without changing the list' do
Deerjikist.create!(platform: platform1, code: code1, tag: tag)
expect {
do_request
}.not_to change { Deerjikist.where(tag: tag).map { |d| [d.platform, d.code] } }
expect(response).to have_http_status(:unprocessable_entity)
expect(json.fetch('errors')).to include(
'deerjikists.0.platform' => [be_present],
)
end
end
context 'when a requested deerjikist belongs to another tag' do
let!(:other_tag) { create(:tag, category: :deerjikist) }
let!(:owned_deerjikist) do
Deerjikist.create!(platform: platform1, code: code1, tag: tag)
end
let!(:conflicting_deerjikist) do
Deerjikist.create!(platform: platform2, code: code2, tag: other_tag)
end
let(:payload) do
[
{ platform: 'nico', code: 'new-code' },
{ platform: platform2, code: code2 },
]
end
before do
other_tag.tag_name.update!(name: 'existing-deerjikist')
end
it 'returns an indexed 422 error and rolls back the complete replacement' do
expect {
do_request
}.not_to change { Deerjikist.order(:platform, :code).pluck(:platform, :code, :tag_id) }
expect(response).to have_http_status(:unprocessable_entity)
expect(json.fetch('errors')).to include(
'deerjikists.1.code' => [include('existing-deerjikist')],
)
expect(owned_deerjikist.reload.tag_id).to eq(tag.id)
expect(conflicting_deerjikist.reload.tag_id).to eq(other_tag.id)
end
end
context 'when a requested deerjikist already belongs to the same tag' do
let!(:existing_deerjikist) do
Deerjikist.create!(platform: platform1, code: code1, tag: tag)
end
let(:payload) do
[
{ platform: platform1, code: code1 },
]
end
it 'keeps the existing row' do
expect {
do_request
}.not_to change { existing_deerjikist.reload.created_at }
expect(response).to have_http_status(:ok)
expect(Deerjikist.where(tag: tag).pluck(:platform, :code))
.to eq([[platform1, code1]])
end
end
context 'when youtube code is handle' do context 'when youtube code is handle' do
let(:channel_id) { 'UCabcdefghijklmnopqrstuv' } let(:channel_id) { 'UCabcdefghijklmnopqrstuv' }
let(:payload) do let(:payload) do
-27
ファイルの表示
@@ -1,27 +0,0 @@
require 'rails_helper'
RSpec.describe 'Wiki body search', type: :request do
let!(:user) { create_member_user! }
it 'searches wiki pages by body text' do
pending '#336 で対応予定'
Wiki::Commit.create_content!(
tag_name: TagName.create!(name: 'wiki_body_search_hit'),
body: 'unique body keyword for wiki search',
created_by_user: user,
message: 'init')
Wiki::Commit.create_content!(
tag_name: TagName.create!(name: 'wiki_body_search_miss'),
body: 'ordinary body',
created_by_user: user,
message: 'init')
get '/wiki/search', params: { body: 'unique body keyword' }
expect(response).to have_http_status(:ok)
expect(json.map { |page| page['title'] }).to include('wiki_body_search_hit')
expect(json.map { |page| page['title'] }).not_to include('wiki_body_search_miss')
end
end
-37
ファイルの表示
@@ -1,37 +0,0 @@
require 'rails_helper'
RSpec.describe 'Wiki restore', type: :request do
let!(:user) { create_member_user! }
def auth_headers user
{ 'X-Transfer-Code' => user.inheritance_code }
end
it 'restores wiki page to previous version' do
pending '#337 で対応予定'
page =
Wiki::Commit.create_content!(
tag_name: TagName.create!(name: 'wiki_restore_page'),
body: 'v1',
created_by_user: user,
message: 'init')
v1 = page.wiki_versions.order(:version_no).last
Wiki::Commit.content!(
page:,
body: 'v2',
created_user: user,
message: 'edit',
base_revision_id: page.current_revision.id)
post "/wiki/#{ page.id }/restore",
params: { version_no: v1.version_no },
headers: auth_headers(user)
expect(response).to have_http_status(:ok)
expect(page.reload.body).to eq('v1')
expect(page.wiki_versions.order(:version_no).last.event_type).to eq('restore')
end
end
+59
ファイルの表示
@@ -0,0 +1,59 @@
require 'rails_helper'
RSpec.describe PostBulkCreator do
it 'limits workers to two and keeps failures in their request slots' do
actor = instance_double(User, id: 123)
allow(User).to receive(:find).with(123) {
instance_double(User, id: 123)
}
mutex = Mutex.new
active = 0
maximum_active = 0
allow(PostCreatePreflight).to receive(:new) do |attributes:, **|
preflight = instance_double(PostCreatePreflight)
allow(preflight).to receive(:run) do
mutex.synchronize do
active += 1
maximum_active = [maximum_active, active].max
end
sleep 0.02
mutex.synchronize { active -= 1 }
attributes.symbolize_keys.merge(
existing_post_id: nil,
field_warnings: { },
base_warnings: [])
end
preflight
end
allow(PostCreator).to receive(:new) do |attributes:, **|
creator = instance_double(PostCreator)
if attributes[:title] == 'broken'
allow(creator).to receive(:create!).and_raise(StandardError, 'broken')
else
post = instance_double(Post, id: attributes[:title].delete_prefix('post ').to_i)
allow(creator).to receive(:create!).and_return(post)
end
creator
end
posts = [
{ 'title' => 'post 1', 'url' => 'https://example.com/1' },
{ 'title' => 'broken', 'url' => 'https://example.com/2' },
{ 'title' => 'post 3', 'url' => 'https://example.com/3' },
{ 'title' => 'post 4', 'url' => 'https://example.com/4' }]
results = described_class.new(
actor:,
posts:,
thumbnails: { }).run.fetch(:results)
expect(maximum_active).to eq(2)
expect(results.length).to eq(posts.length)
expect(results.map { _1[:status] }).to eq(
['created', 'failed', 'created', 'created'])
expect(results[0].dig(:post, :id)).to eq(1)
expect(results[1]).to include(status: 'failed', recoverable: false)
expect(results[2].dig(:post, :id)).to eq(3)
expect(results[3].dig(:post, :id)).to eq(4)
end
end
+82
ファイルの表示
@@ -0,0 +1,82 @@
require 'rails_helper'
RSpec.describe PostCreatePlan do
def create_tag! name, category
Tag.create!(name:, category:)
end
before do
create_tag!('タグ希望', :meta)
create_tag!('ニジラー情報不詳', :meta)
end
it 'plans direct and existing default tags without persisting records' do
counts = [TagName.count, Tag.count]
plan = described_class.new(
attributes: {
url: 'https://example.com/post',
title: 'title',
tags: 'character:new_character',
parent_post_ids: '' }).build!
expect(plan[:tags]).to eq('new_character')
expect(plan[:direct_tag_specs]).to eq(
[{ name: 'new_character', category: :character }])
expect(plan[:default_tag_specs]).to include(
{ name: 'タグ希望', category: :meta },
{ name: 'ニジラー情報不詳', category: :meta })
expect([TagName.count, Tag.count]).to eq(counts)
end
it 'resolves aliases and keeps tag sections separate from canonical names' do
canonical = create_tag!('虹夏', :character)
TagName.create!(name: 'にじか', canonical: canonical.tag_name)
create_tag!('動画', :meta)
plan = described_class.new(
attributes: {
url: 'https://example.com/video',
title: 'video',
tags: '動画 にじか[0:10-0:20]',
duration: '1:00',
parent_post_ids: '' }).build!
expect(plan[:tags].split).to include('動画', '虹夏[0:10-0:20]')
expect(plan[:display_tags]).to include(
{ name: '虹夏',
category: 'character',
section_literals: ['[0:10-0:20]'] })
expect(plan[:video_ms]).to eq(60_000)
end
it 'validates a new tag name without persisting it' do
long_name = 'a' * 256
counts = [TagName.count, Tag.count]
expect {
described_class.new(
attributes: {
url: 'https://example.com/post',
title: 'title',
tags: long_name,
parent_post_ids: '' }).build!
}.to raise_error(ActiveRecord::RecordInvalid) { |error|
expect(error.record.errors[:tags]).not_to be_empty
}
expect([TagName.count, Tag.count]).to eq(counts)
end
it 'ignores duration when the planned tags do not include video' do
plan = described_class.new(
attributes: {
url: 'https://example.com/post',
title: 'title',
tags: 'ordinary_tag',
duration: 'invalid',
parent_post_ids: '' }).build!
expect(plan[:duration]).to eq('invalid')
expect(plan[:video_ms]).to be_nil
end
end
+15 -19
ファイルの表示
@@ -20,12 +20,12 @@ RSpec.describe PostCreator do
allow(PostVersionRecorder).to receive(:record!) allow(PostVersionRecorder).to receive(:record!)
end end
it 'prefers an explicit upload over thumbnail_base' do it 'prefers thumbnail_base over an explicit upload' do
allow(Post).to receive(:resized_thumbnail_attachment).and_return( expect(Post).not_to receive(:resized_thumbnail_attachment)
io: StringIO.new('upload'), allow(Post).to receive(:remote_thumbnail_attachment).and_return(
io: StringIO.new('remote'),
filename: 'resized_thumbnail.jpg', filename: 'resized_thumbnail.jpg',
content_type: 'image/jpeg') content_type: 'image/jpeg')
expect_any_instance_of(Post).not_to receive(:attach_thumbnail_from_url!)
post = described_class.new( post = described_class.new(
actor:, actor:,
@@ -41,13 +41,12 @@ RSpec.describe PostCreator do
end end
it 'uses the common remote thumbnail attach path when thumbnail_base is given' do it 'uses the common remote thumbnail attach path when thumbnail_base is given' do
expect_any_instance_of(Post).to receive(:attach_thumbnail_from_url!) expect(Post).to receive(:remote_thumbnail_attachment)
.with('https://example.com/thumb.jpg') do |post, _url| .with('https://example.com/thumb.jpg')
post.thumbnail.attach( .and_return(
io: StringIO.new('thumbnail'), io: StringIO.new('thumbnail'),
filename: 'thumbnail.jpg', filename: 'thumbnail.jpg',
content_type: 'image/jpeg') content_type: 'image/jpeg')
end
post = described_class.new( post = described_class.new(
actor:, actor:,
@@ -61,8 +60,8 @@ RSpec.describe PostCreator do
expect(post.thumbnail).to be_attached expect(post.thumbnail).to be_attached
end end
it 'keeps creating the post and records a warning when remote thumbnail fetch fails' do it 'does not create a post when remote thumbnail fetch fails' do
allow_any_instance_of(Post).to receive(:attach_thumbnail_from_url!) allow(Post).to receive(:remote_thumbnail_attachment)
.and_raise(Post::RemoteThumbnailFetchFailed, 'サムネール画像を取得できませんでした.') .and_raise(Post::RemoteThumbnailFetchFailed, 'サムネール画像を取得できませんでした.')
creator = described_class.new( creator = described_class.new(
actor:, actor:,
@@ -72,11 +71,8 @@ RSpec.describe PostCreator do
thumbnail_base: 'https://example.com/thumb.jpg', thumbnail_base: 'https://example.com/thumb.jpg',
tags: '' }) tags: '' })
post = creator.create! post_count = Post.count
expect { creator.create! }.to raise_error(Post::RemoteThumbnailFetchFailed)
expect(post.thumbnail_base).to eq('https://example.com/thumb.jpg') expect(Post.count).to eq(post_count)
expect(post.thumbnail).not_to be_attached
expect(creator.field_warnings).to eq(
thumbnail_base: ['サムネール画像を取得できませんでした.'])
end end
end end
-77
ファイルの表示
@@ -1,77 +0,0 @@
require 'rails_helper'
RSpec.describe PostImportRowNormaliser do
def valid_row(overrides = { })
{
sourceRow: '1',
url: 'https://example.com/post',
metadataUrl: 'https://example.com/post',
attributes: { title: 'title', duration: '1' },
provenance: { url: 'manual', title: 'automatic' },
tagSources: { automatic: 'tag', manual: '' }
}.deep_merge(overrides)
end
describe '.normalise!' do
it 'normalises aliases and source rows into a permitted plain hash' do
row = ActionController::Parameters.new(valid_row)
expect(described_class.normalise!([row])).to eq([
{
'source_row' => 1,
'url' => 'https://example.com/post',
'metadata_url' => 'https://example.com/post',
'attributes' => { 'title' => 'title', 'duration' => '1' },
'provenance' => { 'url' => 'manual', 'title' => 'automatic' },
'tag_sources' => { 'automatic' => 'tag', 'manual' => '' }
}
])
end
it 'normalises source rows before checking duplicates' do
rows = [valid_row, valid_row(sourceRow: 1, url: 'https://example.com/other')]
expect { described_class.normalise!(rows) }
.to raise_error(ArgumentError, '元行番号が重複しています.')
end
it 'rejects non-array batches and non-hash rows' do
expect { described_class.normalise!({}) }
.to raise_error(ArgumentError, '取込行の形式が不正です.')
expect { described_class.normalise!(['row']) }
.to raise_error(ArgumentError, '取込行の形式が不正です.')
end
it 'rejects unknown attributes and invalid field types' do
expect { described_class.normalise!([valid_row(attributes: { unknown: 'x' })]) }
.to raise_error(ArgumentError, '取込項目が不正です.')
expect { described_class.normalise!([valid_row(attributes: { title: [] })]) }
.to raise_error(ArgumentError, '取込項目の型が不正です.')
expect { described_class.normalise!([valid_row(attributes: { duration: false })]) }
.to raise_error(ArgumentError, '取込項目の型が不正です.')
end
it 'rejects unknown provenance and tag-source values' do
expect { described_class.normalise!([valid_row(provenance: { title: 'mapped' })]) }
.to raise_error(ArgumentError, '値の由来が不正です.')
expect { described_class.normalise!([valid_row(tagSources: { mapped: 'tag' })]) }
.to raise_error(ArgumentError, 'タグ由来の形式が不正です.')
end
it 'accepts warning fields only at the validation boundary' do
row = valid_row.merge(
fieldWarnings: { title: ['取得できませんでした.'] },
baseWarnings: ['確認してください.']
)
without_warnings = described_class.normalise!([row]).first
with_warnings = described_class.normalise!([row], allow_warning_fields: true).first
expect(without_warnings).not_to include('field_warnings', 'base_warnings')
expect(with_warnings).to include(
'field_warnings' => { 'title' => ['取得できませんでした.'] },
'base_warnings' => ['確認してください.']
)
end
end
end
-152
ファイルの表示
@@ -1,152 +0,0 @@
require 'rails_helper'
RSpec.describe PostImportRunner do
let(:actor) { create(:user, :member) }
def row(source_row: 1, url: 'https://example.com/post')
{
sourceRow: source_row,
url:,
attributes: { title: 'title', tags: '' },
provenance: { url: 'manual', title: 'manual', tags: 'manual' },
tagSources: { automatic: '', manual: '' }
}
end
def preview(source_row: 1, errors: { }, skip_reason: nil, existing_post_id: nil)
{
source_row:,
attributes: { 'title' => 'title', 'tags' => '' },
validation_errors: errors,
skip_reason:,
existing_post_id:
}
end
it 'previews the whole batch once before processing individual rows' do
rows = [row, row(source_row: 2, url: 'https://example.com/two')]
previewer = instance_double(PostImportPreviewer)
allow(PostImportPreviewer).to receive(:new).and_return(previewer)
expect(previewer).to receive(:preview_rows)
.with(
rows: satisfy { _1.map { |row_value| row_value['source_row'] } == [1, 2] },
fetch_metadata: false
)
.and_return([preview, preview(source_row: 2)])
allow(PostCreator).to receive(:new).and_return(
instance_double(PostCreator, create!: create(:post))
)
result = described_class.new(actor:, rows:).run
expect(result).to include(created: 2, skipped: 0, failed: 0)
end
it 'treats validation errors as failures before an existing skip' do
existing = create(:post)
previewer = instance_double(PostImportPreviewer)
allow(PostImportPreviewer).to receive(:new).and_return(previewer)
allow(previewer).to receive(:preview_rows).and_return([
preview(errors: { url: ['URL が重複しています.'] },
skip_reason: 'existing', existing_post_id: existing.id)
])
expect(PostCreator).not_to receive(:new)
result = described_class.new(actor:, rows: [row]).run.fetch(:rows).first
expect(result).to include(
status: 'failed',
errors: { url: ['URL が重複しています.'] },
recoverable: true
)
end
it 'returns the existing post ID for skipped rows' do
existing = create(:post)
previewer = instance_double(PostImportPreviewer)
allow(PostImportPreviewer).to receive(:new).and_return(previewer)
allow(previewer).to receive(:preview_rows).and_return([
preview(skip_reason: 'existing', existing_post_id: existing.id)
])
result = described_class.new(actor:, rows: [row]).run.fetch(:rows).first
expect(result).to eq(
source_row: 1,
status: 'skipped',
existing_post_id: existing.id
)
end
it 'converts a URL uniqueness validation race into a skip' do
existing = create(:post, url: 'https://example.com/race')
invalid = Post.new(url: existing.url)
invalid.errors.add(:url, :taken)
previewer = instance_double(PostImportPreviewer)
allow(PostImportPreviewer).to receive(:new).and_return(previewer)
allow(previewer).to receive(:preview_rows).and_return([preview])
creator = instance_double(PostCreator)
allow(PostCreator).to receive(:new).and_return(creator)
allow(creator).to receive(:create!).and_raise(ActiveRecord::RecordInvalid.new(invalid))
result = described_class.new(
actor:,
rows: [row(url: 'https://EXAMPLE.com/race/')]
).run.fetch(:rows).first
expect(result).to include(status: 'skipped', existing_post_id: existing.id)
end
it 're-raises RecordNotUnique errors unrelated to the posts URL index' do
previewer = instance_double(PostImportPreviewer)
allow(PostImportPreviewer).to receive(:new).and_return(previewer)
allow(previewer).to receive(:preview_rows).and_return([preview])
creator = instance_double(PostCreator)
allow(PostCreator).to receive(:new).and_return(creator)
allow(creator).to receive(:create!)
.and_raise(ActiveRecord::RecordNotUnique, 'other_unique_index')
expect {
described_class.new(actor:, rows: [row]).run
}.to raise_error(ActiveRecord::RecordNotUnique)
end
it 'converts a posts URL index race into a skip' do
existing = create(:post, url: 'https://example.com/index-race')
previewer = instance_double(PostImportPreviewer)
allow(PostImportPreviewer).to receive(:new).and_return(previewer)
allow(previewer).to receive(:preview_rows).and_return([preview])
creator = instance_double(PostCreator)
allow(PostCreator).to receive(:new).and_return(creator)
allow(creator).to receive(:create!).and_raise(
ActiveRecord::RecordNotUnique,
'duplicate key index_posts_on_url'
)
result = described_class.new(
actor:,
rows: [row(url: 'https://EXAMPLE.com/index-race/')]
).run.fetch(:rows).first
expect(result).to include(status: 'skipped', existing_post_id: existing.id)
end
it 'returns thumbnail warnings from PostCreator on a created row' do
previewer = instance_double(PostImportPreviewer)
allow(PostImportPreviewer).to receive(:new).and_return(previewer)
allow(previewer).to receive(:preview_rows).and_return([preview])
created_post = create(:post)
creator = instance_double(
PostCreator,
create!: created_post,
field_warnings: { thumbnail_base: ['サムネール画像を取得できませんでした.'] })
allow(PostCreator).to receive(:new).and_return(creator)
result = described_class.new(actor:, rows: [row]).run.fetch(:rows).first
expect(result).to include(
status: 'created',
field_warnings: { thumbnail_base: ['サムネール画像を取得できませんでした.'] }
)
end
end
-40
ファイルの表示
@@ -1,40 +0,0 @@
require 'rails_helper'
RSpec.describe PostImportUrlListParser do
describe '.parse' do
it 'trims URLs, ignores blank lines, and preserves source line numbers' do
source = " https://example.com/one \r\n\r\nhttps://example.com/two\n"
expect(described_class.parse(source)).to eq([
{ source_row: 1, url: 'https://example.com/one' },
{ source_row: 3, url: 'https://example.com/two' }
])
end
it 'rejects an empty URL list' do
expect { described_class.parse(" \n\r\n") }
.to raise_error(ArgumentError, 'URL を入力してください.')
end
it 'rejects more than 100 non-empty rows' do
source = 101.times.map { |index| "https://example.com/#{ index }" }.join("\n")
expect { described_class.parse(source) }
.to raise_error(ArgumentError, '取込件数は 100 件までです.')
end
it 'includes the original line number in an oversized URL error' do
source = "\n#{ 'a' * (described_class::MAX_URL_BYTES + 1) }"
expect { described_class.parse(source) }
.to raise_error(ArgumentError, '2 行目: URL が長すぎます.')
end
it 'rejects an oversized request before parsing rows' do
source = 'a' * (described_class::MAX_BYTES + 1)
expect { described_class.parse(source) }
.to raise_error(ArgumentError, '入力が大きすぎます.')
end
end
end
+52
ファイルの表示
@@ -0,0 +1,52 @@
require 'rails_helper'
require 'base64'
require 'tempfile'
RSpec.describe PostThumbnailUploadValidator do
def with_upload bytes, content_type:, filename:
tempfile = Tempfile.new(['thumbnail-upload', File.extname(filename)])
tempfile.binmode
tempfile.write(bytes)
tempfile.rewind
upload = ActionDispatch::Http::UploadedFile.new(
tempfile:,
filename:,
type: content_type)
yield upload
ensure
tempfile&.close!
end
it 'accepts a raster upload after decoding and rewinds it' do
gif = Base64.decode64('R0lGODdhAQABAIABAP///wAAACwAAAAAAQABAAACAkQBADs=')
with_upload(gif, content_type: 'image/gif', filename: 'thumbnail.gif') do |upload|
expect { described_class.validate!(upload) }.not_to raise_error
expect(upload.read(6)).to eq('GIF87a')
end
end
it 'rejects SVG content disguised as a raster MIME type' do
with_upload(
'<svg width="10" height="10"></svg>',
content_type: 'image/png',
filename: 'thumbnail.png') do |upload|
expect { described_class.validate!(upload) }
.to raise_error(
described_class::InvalidUpload,
'サムネイル画像の形式が不正です.')
end
end
it 'rejects non-raster content disguised as an image' do
with_upload(
'%PDF-1.7',
content_type: 'image/png',
filename: 'thumbnail.png') do |upload|
expect { described_class.validate!(upload) }
.to raise_error(
described_class::InvalidUpload,
'サムネイル画像の形式が不正です.')
end
end
end
+11 -10
ファイルの表示
@@ -2,7 +2,7 @@ require 'rails_helper'
RSpec.describe Preview::ThumbnailFetcher do RSpec.describe Preview::ThumbnailFetcher do
describe '.fetch' do describe '.fetch' do
it 'rejects svg thumbnails' do it 'accepts svg thumbnails for the common safe rasterisation path' do
page = Preview::HttpFetcher::Response.new( page = Preview::HttpFetcher::Response.new(
'<meta property="og:image" content="https://example.com/thumb.svg">', '<meta property="og:image" content="https://example.com/thumb.svg">',
'text/html', 'text/html',
@@ -12,8 +12,9 @@ RSpec.describe Preview::ThumbnailFetcher do
'image/svg+xml', 'image/svg+xml',
'https://example.com/thumb.svg') 'https://example.com/thumb.svg')
allow(Preview::UrlSafety).to receive(:validate) allow(Preview::UrlSafety).to receive(:validate) do |url|
.and_return([URI.parse('https://example.com/page'), ['203.0.113.10']]) [URI.parse(url), ['203.0.113.10']]
end
allow(Preview::HttpFetcher).to receive(:fetch) allow(Preview::HttpFetcher).to receive(:fetch)
.with('https://example.com/page', max_bytes: described_class::HTML_MAX_BYTES) .with('https://example.com/page', max_bytes: described_class::HTML_MAX_BYTES)
.and_return(page) .and_return(page)
@@ -21,9 +22,7 @@ RSpec.describe Preview::ThumbnailFetcher do
.with('https://example.com/thumb.svg') .with('https://example.com/thumb.svg')
.and_return(svg) .and_return(svg)
expect { expect(described_class.fetch('https://example.com/page')).to eq('<svg></svg>')
described_class.fetch('https://example.com/page')
}.to raise_error(Preview::ThumbnailFetcher::GenerationFailed)
end end
it 'accepts allowed image content type with parameters' do it 'accepts allowed image content type with parameters' do
@@ -32,12 +31,13 @@ RSpec.describe Preview::ThumbnailFetcher do
'text/html', 'text/html',
'https://example.com/page') 'https://example.com/page')
image = Preview::HttpFetcher::Response.new( image = Preview::HttpFetcher::Response.new(
'jpeg-bytes', "\xFF\xD8\xFFjpeg-bytes".b,
'image/jpeg; charset=binary', 'image/jpeg; charset=binary',
'https://example.com/thumb.jpg') 'https://example.com/thumb.jpg')
allow(Preview::UrlSafety).to receive(:validate) allow(Preview::UrlSafety).to receive(:validate) do |url|
.and_return([URI.parse('https://example.com/page'), ['203.0.113.10']]) [URI.parse(url), ['203.0.113.10']]
end
allow(Preview::HttpFetcher).to receive(:fetch) allow(Preview::HttpFetcher).to receive(:fetch)
.with('https://example.com/page', max_bytes: described_class::HTML_MAX_BYTES) .with('https://example.com/page', max_bytes: described_class::HTML_MAX_BYTES)
.and_return(page) .and_return(page)
@@ -45,7 +45,8 @@ RSpec.describe Preview::ThumbnailFetcher do
.with('https://example.com/thumb.jpg') .with('https://example.com/thumb.jpg')
.and_return(image) .and_return(image)
expect(described_class.fetch('https://example.com/page')).to eq('jpeg-bytes') expect(described_class.fetch('https://example.com/page'))
.to eq("\xFF\xD8\xFFjpeg-bytes".b)
end end
end end
+4
ファイルの表示
@@ -268,6 +268,10 @@ RSpec.describe Youtube::Sync do
expect(tag_ids).to include(deerjikist_tag.id) expect(tag_ids).to include(deerjikist_tag.id)
expect(tag_ids).not_to include(Tag.no_deerjikist.id) expect(tag_ids).not_to include(Tag.no_deerjikist.id)
expect(PostTag.exists?(post:, tag: Tag.no_deerjikist)).to be(false)
expect(Tag.no_deerjikist.reload.post_count).to eq(0)
expect(deerjikist_tag.reload.post_count).to eq(1)
expect(PostVersionRecorder).to have_received(:ensure_snapshot!).with( expect(PostVersionRecorder).to have_received(:ensure_snapshot!).with(
post, post,
created_by_user: nil created_by_user: nil
+57 -47
ファイルの表示
@@ -1,36 +1,35 @@
require "rails_helper" require 'rails_helper'
RSpec.describe "nico:sync" do RSpec.describe 'nico:sync' do
def stub_python(json_array) def stub_python(json_array)
status = instance_double(Process::Status, success?: true) status = instance_double(Process::Status, success?: true)
allow(Open3).to receive(:capture3).and_return([json_array.to_json, "", status]) allow(Open3).to receive(:capture3).and_return([json_array.to_json, '', status])
end end
def create_tag!(name, category:) def create_tag!(name, category:)
tn = TagName.find_undiscard_or_create_by!(name: name.to_s.strip) Tag.find_or_create_by_tag_name!(name, category:)
Tag.find_undiscard_or_create_by!(tag_name_id: tn.id) { |t| t.category = category }
end end
def link_nico_to_tag!(nico_tag, tag) def link_nico_to_tag!(nico_tag, tag)
NicoTagRelation.create!(nico_tag_id: nico_tag.id, tag_id: tag.id) NicoTagRelation.create!(nico_tag_id: nico_tag.id, tag_id: tag.id)
end end
it "既存 post を見つけて、nico tag と linked tag を追加し、差分が出たら bot を付ける" do it '既存 post を見つけて、nico tag と linked tag を追加し、差分が出たら bot を付ける' do
# 既存 post(正規表現で拾われるURL) # 既存 post(正規表現で拾われるURL)
post = Post.create!( post = Post.create!(
title: "old", title: 'old',
url: "https://www.nicovideo.jp/watch/sm9", url: 'https://www.nicovideo.jp/watch/sm9',
uploaded_user: nil uploaded_user: nil
) )
# 既存の非nicoタグ(kept_non_nico_ids) # 既存の非nicoタグ(kept_non_nico_ids)
kept_general = create_tag!("spec_kept", category: "general") kept_general = create_tag!('spec_kept', category: 'general')
PostTag.create!(post: post, tag: kept_general) PostTag.create!(post: post, tag: kept_general)
# 追加される linked tag を準備(nico tag に紐付く一般タグ) # 追加される linked tag を準備(nico tag に紐付く一般タグ)
linked = create_tag!("spec_linked", category: "general") linked = create_tag!('spec_linked', category: 'general')
nico = create_tag!("nico:AAA", category: "nico") nico = create_tag!('nico:AAA', category: 'nico')
link_nico_to_tag!(nico, linked) link_nico_to_tag!(nico, linked)
# bot / tagme は task 内で使うので作っておく(Tag.bot/tagme がある前提) # bot / tagme は task 内で使うので作っておく(Tag.bot/tagme がある前提)
@@ -46,22 +45,22 @@ RSpec.describe "nico:sync" do
'deleted_at' => '2026-01-31 00:00:00' }]) 'deleted_at' => '2026-01-31 00:00:00' }])
# 外部HTTPは今回「既存 post なので呼ばれない」はずだが、念のため塞ぐ # 外部HTTPは今回「既存 post なので呼ばれない」はずだが、念のため塞ぐ
allow(URI).to receive(:open).and_return(StringIO.new("<html></html>")) allow(URI).to receive(:open).and_return(StringIO.new('<html></html>'))
run_rake_task("nico:sync") run_rake_task('nico:sync')
post.reload post.reload
active_tag_names = post.tags.joins(:tag_name).pluck("tag_names.name") active_tag_names = post.tags.joins(:tag_name).pluck('tag_names.name')
expect(active_tag_names).to include("spec_kept") expect(active_tag_names).to include('spec_kept')
expect(active_tag_names).to include("nico:AAA") expect(active_tag_names).to include('nico:AAA')
expect(active_tag_names).to include("spec_linked") expect(active_tag_names).to include('spec_linked')
expect(post.original_created_from).to eq(Time.iso8601('2026-01-01T03:34:00Z')) expect(post.original_created_from).to eq(Time.iso8601('2026-01-01T03:34:00Z'))
expect(post.original_created_before).to eq(Time.iso8601('2026-01-01T03:35:00Z')) expect(post.original_created_before).to eq(Time.iso8601('2026-01-01T03:35:00Z'))
# 差分が出るので bot が付く(kept_non_nico_ids != desired_non_nico_ids) # 差分が出るので bot が付く(kept_non_nico_ids != desired_non_nico_ids)
expect(active_tag_names).to include("bot操作") expect(active_tag_names).to include('bot操作')
end end
it '既存 post のサムネール取得に共通 attach 経路を使ふ' do it '既存 post のサムネール取得に共通 attach 経路を使ふ' do
@@ -73,11 +72,11 @@ RSpec.describe "nico:sync" do
Tag.tagme Tag.tagme
stub_python([{ 'code' => 'sm9', 'title' => 't', 'tags' => [] }]) stub_python([{ 'code' => 'sm9', 'title' => 't', 'tags' => [] }])
allow(URI).to receive(:open) allow(URI).to receive(:open) do
.and_return( StringIO.new(
StringIO.new( '<meta name="thumbnail" content="https://example.com/thumb.jpg">')
'<meta name="thumbnail" content="https://example.com/thumb.jpg">')) end
expect(post).to receive(:attach_thumbnail_from_url!) expect_any_instance_of(Post).to receive(:attach_thumbnail_from_url!)
.with('https://example.com/thumb.jpg') .with('https://example.com/thumb.jpg')
run_rake_task('nico:sync') run_rake_task('nico:sync')
@@ -92,53 +91,63 @@ RSpec.describe "nico:sync" do
Tag.tagme Tag.tagme
stub_python([{ 'code' => 'sm9', 'title' => 't', 'tags' => [] }]) stub_python([{ 'code' => 'sm9', 'title' => 't', 'tags' => [] }])
allow(URI).to receive(:open) allow(URI).to receive(:open) do
.and_return( StringIO.new(
StringIO.new( '<meta name="thumbnail" content="https://example.com/thumb.jpg">')
'<meta name="thumbnail" content="https://example.com/thumb.jpg">')) end
expect(post).to receive(:attach_thumbnail_from_url!) calls = 0
.with('https://example.com/thumb.jpg') allow_any_instance_of(Post).to receive(:attach_thumbnail_from_url!) do
.twice calls += 1
.and_raise(Post::RemoteThumbnailFetchFailed, 'failed') raise Post::RemoteThumbnailFetchFailed, 'failed'
end
2.times do 2.times do
run_rake_task('nico:sync') run_rake_task('nico:sync')
end end
expect(calls).to eq(2)
end end
it "既存 post にあった古い nico tag は active から外され、履歴として discard される" do it '古い nico tag の関連を物理削除し、変更前後の履歴を version に残す' do
post = Post.create!( post = Post.create!(
title: "old", title: 'old',
url: "https://www.nicovideo.jp/watch/sm9", url: 'https://www.nicovideo.jp/watch/sm9',
uploaded_user: nil uploaded_user: nil
) )
# 旧nicoタグ(今回の同期結果に含まれない) # 旧nicoタグ(今回の同期結果に含まれない)
old_nico = create_tag!("nico:OLD", category: "nico") old_nico = create_tag!('nico:OLD', category: 'nico')
old_pt = PostTag.create!(post: post, tag: old_nico) PostTag.create!(post:, tag: old_nico)
expect(old_pt.discarded_at).to be_nil create_post_version_for!(post)
# 今回は NEW のみ欲しい # 今回は NEW のみ欲しい
new_nico = create_tag!("nico:NEW", category: "nico") new_nico = create_tag!('nico:NEW', category: 'nico')
# bot/tagme 念のため # bot/tagme 念のため
Tag.bot Tag.bot
Tag.tagme Tag.tagme
stub_python([{ "code" => "sm9", "title" => "t", "tags" => ["NEW"] }]) stub_python([{ 'code' => 'sm9', 'title' => 't', 'tags' => ['NEW'] }])
allow(URI).to receive(:open).and_return(StringIO.new("<html></html>")) allow(URI).to receive(:open).and_return(StringIO.new('<html></html>'))
run_rake_task("nico:sync") run_rake_task('nico:sync')
# OLD は active から外れる(discarded_at が入る) expect(PostTag.exists?(post:, tag: old_nico)).to be(false)
old_pts = PostTag.where(post_id: post.id, tag_id: old_nico.id).order(:id).to_a expect(old_nico.reload.post_count).to eq(0)
expect(old_pts.last.discarded_at).to be_present expect(new_nico.reload.post_count).to eq(1)
versions = post.post_versions.order(:version_no)
expect(versions.first.tags_json.map { |item| item.fetch('id') })
.to include(old_nico.id)
expect(versions.last.tags_json.map { |item| item.fetch('id') })
.to include(new_nico.id)
expect(versions.last.tags_json.map { |item| item.fetch('id') })
.not_to include(old_nico.id)
# NEW は active にいる # NEW は active にいる
post.reload post.reload
active_names = post.tags.joins(:tag_name).pluck("tag_names.name") active_names = post.tags.joins(:tag_name).pluck('tag_names.name')
expect(active_names).to include("nico:NEW") expect(active_names).to include('nico:NEW')
expect(active_names).not_to include("nico:OLD") expect(active_names).not_to include('nico:OLD')
end end
def snapshot_tags(post) def snapshot_tags(post)
@@ -154,6 +163,7 @@ RSpec.describe "nico:sync" do
url: post.url, url: post.url,
thumbnail_base: post.thumbnail_base, thumbnail_base: post.thumbnail_base,
tags: snapshot_tags(post), tags: snapshot_tags(post),
tags_json: post.snapshot_tags_json,
parent_post_ids: post.snapshot_parent_post_ids.join(' '), parent_post_ids: post.snapshot_parent_post_ids.join(' '),
original_created_from: post.original_created_from, original_created_from: post.original_created_from,
original_created_before: post.original_created_before, original_created_before: post.original_created_before,
+42 -37
ファイルの表示
@@ -1,8 +1,9 @@
import { AnimatePresence, LayoutGroup, MotionConfig, motion } from 'framer-motion' import { AnimatePresence, LayoutGroup, MotionConfig, motion } from 'framer-motion'
import { Fragment, useEffect, useMemo, useState } from 'react' import { Fragment, useEffect, useMemo, useState } from 'react'
import { BrowserRouter, import { createBrowserRouter,
Navigate, Navigate,
Route, Route,
RouterProvider,
Routes, Routes,
useLocation } from 'react-router-dom' useLocation } from 'react-router-dom'
@@ -154,7 +155,7 @@ const PostDetailRoute = ({ user }: { user: User | null }) => {
} }
const App: FC = () => { const RoutedApp: FC = () => {
const [user, setUser] = useState<User | null> (null) const [user, setUser] = useState<User | null> (null)
const [status, setStatus] = useState (200) const [status, setStatus] = useState (200)
const behaviourSettings = useClientBehaviourSettings () const behaviourSettings = useClientBehaviourSettings ()
@@ -253,42 +254,46 @@ const App: FC = () => {
} }
return ( return (
<> <DialogueProvider>
<RouteBlockerOverlay/> <UnsavedChangesGuardProvider>
{import.meta.env.DEV && <DevModeWatermark/>} <KeyboardShortcutsProvider>
<MotionConfig
reducedMotion={
animationMode === 'normal'
? 'never'
: animationMode === 'reduced'
? 'user'
: 'always'
}>
<LayoutWrapper>
<motion.div
layout={animationMode === 'off' ? false : 'position'}
transition={{ layout: appLayoutTransition }}
className="relative flex h-dvh w-full flex-col overflow-y-hidden">
<TopNav user={user}/>
<RouteTransitionWrapper
animationMode={animationMode}
user={user}
setUser={setUser}/>
</motion.div>
</LayoutWrapper>
</MotionConfig>
<BrowserRouter> <Toaster/>
<DialogueProvider> </KeyboardShortcutsProvider>
<UnsavedChangesGuardProvider> </UnsavedChangesGuardProvider>
<KeyboardShortcutsProvider> </DialogueProvider>)
<MotionConfig
reducedMotion={
animationMode === 'normal'
? 'never'
: animationMode === 'reduced'
? 'user'
: 'always'
}>
<LayoutWrapper>
<motion.div
layout={animationMode === 'off' ? false : 'position'}
transition={{ layout: appLayoutTransition }}
className="relative flex flex-col h-dvh w-full overflow-y-hidden">
<TopNav user={user}/>
<RouteTransitionWrapper
animationMode={animationMode}
user={user}
setUser={setUser}/>
</motion.div>
</LayoutWrapper>
</MotionConfig>
<Toaster/>
</KeyboardShortcutsProvider>
</UnsavedChangesGuardProvider>
</DialogueProvider>
</BrowserRouter>
</>)
} }
const router = createBrowserRouter ([{
path: '*',
element: <RoutedApp/> }])
const App: FC = () => (
<>
<RouteBlockerOverlay/>
{import.meta.env.DEV && <DevModeWatermark/>}
<RouterProvider router={router}/>
</>)
export default App export default App
+9 -5
ファイルの表示
@@ -78,19 +78,23 @@ describe ('PostEditForm', () => {
render (<PostEditForm post={post} onSave={vi.fn ()}/>) render (<PostEditForm post={post} onSave={vi.fn ()}/>)
expect (screen.getByPlaceholderText ('例: 2 / 2.5 / 1:23')).toHaveValue ('180.5') expect (screen.getByText ('動画時間').parentElement?.querySelector ('input'))
.toHaveValue ('180.5')
const tags = screen.getAllByRole ('textbox')[2] const tags = screen.getAllByRole ('textbox')[2]
fireEvent.change (tags, { target: { value: 'general-tag' } }) fireEvent.change (tags, { target: { value: 'general-tag' } })
expect (screen.queryByPlaceholderText ('例: 2 / 2.5 / 1:23')).not.toBeInTheDocument () expect (screen.queryByText ('動画時間')).not.toBeInTheDocument ()
fireEvent.change (tags, { fireEvent.change (tags, {
target: { value: '動画 general-tag' }, target: { value: '動画 general-tag' },
}) })
expect (screen.getByPlaceholderText ('例: 2 / 2.5 / 1:23')).toHaveValue ('180.5') expect (screen.getByText ('動画時間').parentElement?.querySelector ('input'))
.toHaveValue ('180.5')
}) })
it ('shows deduplicated original-created endpoint errors on the shared datetime field', async () => { it (
'shows deduplicated original-created endpoint errors on the shared datetime field',
async () => {
const post = buildPost () const post = buildPost ()
api.isApiError.mockReturnValue (true) api.isApiError.mockReturnValue (true)
postsApi.updatePost.mockRejectedValueOnce ({ postsApi.updatePost.mockRejectedValueOnce ({
@@ -112,5 +116,5 @@ describe ('PostEditForm', () => {
expect (await screen.findByText ('日時を確認してください.')).toBeInTheDocument () expect (await screen.findByText ('日時を確認してください.')).toBeInTheDocument ()
expect (screen.getByText ('終了を確認してください.')).toBeInTheDocument () expect (screen.getByText ('終了を確認してください.')).toBeInTheDocument ()
expect (screen.getAllByText ('日時を確認してください.')).toHaveLength (1) expect (screen.getAllByText ('日時を確認してください.')).toHaveLength (1)
}) })
}) })
+1 -1
ファイルの表示
@@ -73,7 +73,7 @@ describe ('PostOriginalCreatedTimeField', () => {
setOriginalCreatedBefore={vi.fn ()}/>, setOriginalCreatedBefore={vi.fn ()}/>,
) )
const input = screen.getDisplayValue ('2024-01-01T12:34') const input = screen.getByDisplayValue ('2024-01-01T12:34')
fireEvent.change (input, { target: { value: '2024-01-01T12:35' } }) fireEvent.change (input, { target: { value: '2024-01-01T12:35' } })
expect (setFrom).toHaveBeenCalledWith ('2024-01-01T03:35Z') expect (setFrom).toHaveBeenCalledWith ('2024-01-01T03:35Z')
-13
ファイルの表示
@@ -6,7 +6,6 @@ import { createPath, useNavigate } from 'react-router-dom'
import { useOverlayStore } from '@/components/RouteBlockerOverlay' import { useOverlayStore } from '@/components/RouteBlockerOverlay'
import { prefetchForURL } from '@/lib/prefetchers' import { prefetchForURL } from '@/lib/prefetchers'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings' import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { useUnsavedChangesGuard } from '@/lib/useUnsavedChangesGuard'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import type { AnchorHTMLAttributes, MouseEvent, TouchEvent } from 'react' import type { AnchorHTMLAttributes, MouseEvent, TouchEvent } from 'react'
@@ -36,7 +35,6 @@ export default forwardRef<HTMLAnchorElement, Props> (({
const navigate = useNavigate () const navigate = useNavigate ()
const qc = useQueryClient () const qc = useQueryClient ()
const behaviourSettings = useClientBehaviourSettings () const behaviourSettings = useClientBehaviourSettings ()
const { confirmDiscardNavigation } = useUnsavedChangesGuard ()
const linkPreloadMode = behaviourSettings.linkPreload ?? 'intent' const linkPreloadMode = behaviourSettings.linkPreload ?? 'intent'
const path = useMemo ( const path = useMemo (
() => typeof to === 'string' ? to : createPath (to), () => typeof to === 'string' ? to : createPath (to),
@@ -45,10 +43,6 @@ export default forwardRef<HTMLAnchorElement, Props> (({
const url = useMemo (() => { const url = useMemo (() => {
return (new URL (path, window.location.origin)).toString () return (new URL (path, window.location.origin)).toString ()
}, [path]) }, [path])
const nextPathname = useMemo (
() => (new URL (path, window.location.origin)).pathname,
[path],
)
const setOverlay = useOverlayStore (s => s.setActive) const setOverlay = useOverlayStore (s => s.setActive)
const doPrefetch = async () => { const doPrefetch = async () => {
@@ -93,13 +87,6 @@ export default forwardRef<HTMLAnchorElement, Props> (({
ev.preventDefault () ev.preventDefault ()
if (nextPathname !== window.location.pathname)
{
const confirmed = await confirmDiscardNavigation ()
if (!(confirmed))
return
}
flushSync (() => { flushSync (() => {
setOverlay (true) setOverlay (true)
}) })
+4 -3
ファイルの表示
@@ -75,8 +75,6 @@ const TagLink: FC<Props> = ({ tag,
const spanClass = 'tag-link-colour' const spanClass = 'tag-link-colour'
const linkClass = 'tag-link-colour tag-link-hover-colour' const linkClass = 'tag-link-colour tag-link-hover-colour'
const textClass = 'group min-w-0 max-w-full overflow-hidden align-bottom' const textClass = 'group min-w-0 max-w-full overflow-hidden align-bottom'
const rootClass =
'inline-flex min-w-0 max-w-full flex-nowrap items-stretch align-baseline gap-x-1 md:items-baseline'
const markerWrapClass = 'shrink-0 self-start md:self-auto' const markerWrapClass = 'shrink-0 self-start md:self-auto'
const countClass = 'shrink-0 self-end md:self-auto' const countClass = 'shrink-0 self-end md:self-auto'
const matchedAlias = isFullTag (tag) ? tag.matchedAlias : null const matchedAlias = isFullTag (tag) ? tag.matchedAlias : null
@@ -84,7 +82,10 @@ const TagLink: FC<Props> = ({ tag,
?? (matchedAlias == null ? tag.name : `${ matchedAlias }${ tag.name }`) ?? (matchedAlias == null ? tag.name : `${ matchedAlias }${ tag.name }`)
return ( return (
<span className={rootClass}> <span
className={cn (
'inline-flex min-w-0 max-w-full flex-nowrap items-stretch align-baseline',
'gap-x-1 md:items-baseline')}>
{(linkFlg && withWiki && isFullTag (tag)) && ( {(linkFlg && withWiki && isFullTag (tag)) && (
<span className={markerWrapClass}> <span className={markerWrapClass}>
{(tag.materialId != null || tag.hasWiki || tag.hasDeerjikists) {(tag.materialId != null || tag.hasWiki || tag.hasDeerjikists)
+2 -4
ファイルの表示
@@ -17,21 +17,19 @@ describe ('menuOutline', () => {
for (const role of ['member', 'admin'] as const) for (const role of ['member', 'admin'] as const)
{ {
expect (submenuItem (role, '広場', '追加')?.visible).toBe (true) expect (submenuItem (role, '広場', '追加')?.visible).toBe (true)
expect (submenuItem (role, '広場', '取込')?.visible).toBe (true)
expect (submenuItem (role, '素材', '追加')?.visible).toBe (true) expect (submenuItem (role, '素材', '追加')?.visible).toBe (true)
expect (submenuItem (role, 'Wiki', '新規')?.visible).toBe (true) expect (submenuItem (role, 'Wiki', '新規')?.visible).toBe (true)
expect (submenuItem (role, 'Wiki', '編輯')?.visible).toBe (true) expect (submenuItem (role, 'Wiki', '編輯')?.visible).toBe (true)
} }
expect (submenuItem ('guest', '広場', '追加')?.visible).toBe (false) expect (submenuItem ('guest', '広場', '追加')?.visible).toBe (false)
expect (submenuItem ('guest', '広場', '取込')?.visible).toBe (false)
expect (submenuItem ('guest', '素材', '追加')?.visible).toBe (false) expect (submenuItem ('guest', '素材', '追加')?.visible).toBe (false)
expect (submenuItem ('guest', 'Wiki', '新規')?.visible).toBe (false) expect (submenuItem ('guest', 'Wiki', '新規')?.visible).toBe (false)
expect (submenuItem ('guest', 'Wiki', '編輯')?.visible).toBe (false) expect (submenuItem ('guest', 'Wiki', '編輯')?.visible).toBe (false)
}) })
it ('uses /posts/new as the import entrypoint', () => { it ('uses /posts/new as the post creation entrypoint', () => {
expect (submenuItem ('member', '広場', '取込')?.to).toBe ('/posts/new') expect (submenuItem ('member', '広場', '追加')?.to).toBe ('/posts/new')
}) })
it ('keeps material suppression admin-only', () => { it ('keeps material suppression admin-only', () => {
+3 -1
ファイルの表示
@@ -80,7 +80,9 @@ describe ('DialogueProvider', () => {
expect (dialogue).toHaveClass ('max-h-[calc(100dvh-1rem)]', 'flex-col', 'max-w-3xl') expect (dialogue).toHaveClass ('max-h-[calc(100dvh-1rem)]', 'flex-col', 'max-w-3xl')
await waitFor (() => expect (screen.getByRole ('button', { name: '左操作' })) await waitFor (() => expect (screen.getByRole ('button', { name: '左操作' }))
.toBeInTheDocument ()) .toBeInTheDocument ())
expect (screen.getByRole ('button', { name: '左操作' })).toHaveClass ('w-full', 'sm:w-auto') expect (screen.getByRole ('button', { name: '左操作' })).toHaveClass (
'w-full',
'md:w-auto')
fireEvent.click (screen.getByRole ('button', { name: '左操作' })) fireEvent.click (screen.getByRole ('button', { name: '左操作' }))
await waitFor (() => expect (action).toHaveBeenCalledTimes (1)) await waitFor (() => expect (action).toHaveBeenCalledTimes (1))
-10
ファイルの表示
@@ -2,8 +2,6 @@ import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest' import { describe, expect, it, vi } from 'vitest'
import { buildPostImportRow } from '@/test/postImportFactories' import { buildPostImportRow } from '@/test/postImportFactories'
import { buildUser } from '@/test/factories'
import { renderWithProviders } from '@/test/render'
import type { DialogueFormControls } from '@/lib/dialogues/useDialogue' import type { DialogueFormControls } from '@/lib/dialogues/useDialogue'
@@ -14,14 +12,6 @@ vi.mock ('@/components/posts/PostCreationDataFields', () => ({
})) }))
describe ('PostCreationDataFields usage', () => { describe ('PostCreationDataFields usage', () => {
it ('is used by PostNewPage', async () => {
const { default: PostNewPage } = await import ('@/pages/posts/PostNewPage')
renderWithProviders (<PostNewPage user={buildUser ({ role: 'member' })}/>)
expect (screen.getByTestId ('shared-fields')).toBeInTheDocument ()
})
it ('is used by PostImportRowForm', async () => { it ('is used by PostImportRowForm', async () => {
const { default: PostImportRowForm } = await import ( const { default: PostImportRowForm } = await import (
'@/components/posts/import/PostImportRowForm') '@/components/posts/import/PostImportRowForm')
+4 -2
ファイルの表示
@@ -135,7 +135,7 @@ describe ('PostImportRowForm', () => {
}) })
it ( it (
'shows the shared creation field order without duration and without file upload UI', 'shows upload input only when the thumbnail URL is blank',
async () => { async () => {
let actions: DialogueFormAction[] = [] let actions: DialogueFormAction[] = []
const controls: DialogueFormControls = { const controls: DialogueFormControls = {
@@ -164,7 +164,9 @@ describe ('PostImportRowForm', () => {
'オリジナルの作成日時', 'オリジナルの作成日時',
'タグ', 'タグ',
'親投稿']) '親投稿'])
expect (container.querySelector ('input[type="file"]')).toBeNull () expect (container.querySelector ('input[type="file"]')).toHaveAttribute (
'accept',
'image/*')
expect (screen.queryByPlaceholderText ('例: 2 / 2.5 / 1:23')).not.toBeInTheDocument () expect (screen.queryByPlaceholderText ('例: 2 / 2.5 / 1:23')).not.toBeInTheDocument ()
expect (screen.getByDisplayValue ('tag1')).toBeInTheDocument () expect (screen.getByDisplayValue ('tag1')).toBeInTheDocument ()
+1 -1
ファイルの表示
@@ -10,8 +10,8 @@ import { hasThumbnailBaseValue, hasVideoTag } from '@/lib/postImportRows'
import type { FC } from 'react' import type { FC } from 'react'
import type { PostImportEditableDraft, PostImportRow } from '@/lib/postImportSession'
import type { DialogueFormControls } from '@/lib/dialogues/useDialogue' import type { DialogueFormControls } from '@/lib/dialogues/useDialogue'
import type { PostImportEditableDraft, PostImportRow } from '@/lib/postImportTypes'
type Draft = PostImportEditableDraft type Draft = PostImportEditableDraft
+125 -122
ファイルの表示
@@ -4,19 +4,22 @@ import { Button } from '@/components/ui/button'
import PostImportThumbnailPreview from '@/components/posts/import/PostImportThumbnailPreview' import PostImportThumbnailPreview from '@/components/posts/import/PostImportThumbnailPreview'
import PostImportStatusBadge from '@/components/posts/import/PostImportStatusBadge' import PostImportStatusBadge from '@/components/posts/import/PostImportStatusBadge'
import { displayPostImportStatus } from '@/components/posts/import/postImportRowStatus' import { displayPostImportStatus } from '@/components/posts/import/postImportRowStatus'
import { hasVideoTag } from '@/lib/postImportSession' import {
import { canEditReviewRow, canRetryResultRow } from '@/lib/postImportSession' canEditReviewRow,
canRetryResultRow,
hasVideoTag,
} from '@/lib/postImportRows'
import { cn, originalCreatedAtString } from '@/lib/utils' import { cn, originalCreatedAtString } from '@/lib/utils'
import type { FC } from 'react' import type { FC } from 'react'
import type { PostImportRow } from '@/lib/postImportSession' import type { PostImportRow } from '@/lib/postImportTypes'
type Props = { type Props = {
row: PostImportRow row: PostImportRow
displayNumber?: number displayNumber?: number
onEdit?: () => void onEdit?: () => void
onRetry?: () => void onRetry?: () => void
onToggleSkip?: (checked: boolean) => void onToggleSkip?: (checked: boolean) => void
rowMessages?: string[] rowMessages?: string[]
editDisabled?: boolean editDisabled?: boolean
@@ -72,123 +75,123 @@ const PostImportRowSummary: FC<Props> = (
return ( return (
<> <>
<div <div
className={cn ( className={cn (
'hidden items-center gap-4 rounded-lg border p-4 md:grid', 'hidden items-center gap-4 rounded-lg border p-4 md:grid',
'md:grid-cols-[4rem_5rem_minmax(0,1fr)_auto_auto]', 'md:grid-cols-[4rem_5rem_minmax(0,1fr)_auto_auto]',
'transition-shadow hover:shadow-sm')}> 'transition-shadow hover:shadow-sm')}>
<div className="space-y-1"> <div className="space-y-1">
<div className="text-sm font-medium">#{rowNumber}</div> <div className="text-sm font-medium">#{rowNumber}</div>
</div> </div>
<PostImportThumbnailPreview <PostImportThumbnailPreview
url={String (row.attributes.thumbnailBase ?? '')} url={String (row.attributes.thumbnailBase ?? '')}
file={row.thumbnailFile} file={row.thumbnailFile}
className="h-16 w-16"/> className="h-16 w-16"/>
<div className="min-w-0 space-y-1"> <div className="min-w-0 space-y-1">
<div className="line-clamp-2 text-sm font-medium"> <div className="line-clamp-2 text-sm font-medium">
{String (row.attributes.title ?? '')} {String (row.attributes.title ?? '')}
</div> </div>
<div className="truncate text-xs text-neutral-600 dark:text-neutral-300"> <div className="truncate text-xs text-neutral-600 dark:text-neutral-300">
{row.url} {row.url}
</div> </div>
<PostImportTagLinks tags={row.displayTags}/> <PostImportTagLinks tags={row.displayTags}/>
<div className="text-xs text-neutral-500 dark:text-neutral-400"> <div className="text-xs text-neutral-500 dark:text-neutral-400">
{summaryDate (row)} {summaryDate (row)}
</div> </div>
{showDuration && ( {showDuration && (
<div className="text-xs text-neutral-500 dark:text-neutral-400"> <div className="text-xs text-neutral-500 dark:text-neutral-400">
{duration} {duration}
</div>)} </div>)}
{warning && ( {warning && (
<div className="text-xs text-amber-700 dark:text-amber-200"> <div className="text-xs text-amber-700 dark:text-amber-200">
{warning} {warning}
</div>)} </div>)}
<FieldError messages={rowMessages}/> <FieldError messages={rowMessages}/>
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
{displayStatus != null && <PostImportStatusBadge value={displayStatus}/>} {displayStatus != null && <PostImportStatusBadge value={displayStatus}/>}
</div> </div>
<div className="flex justify-end"> <div className="flex justify-end">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{skipControl} {skipControl}
{showActions && editVisible && ( {showActions && editVisible && (
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
onClick={onEdit} onClick={onEdit}
disabled={editDisabled === true || !(editAllowed)}> disabled={editDisabled === true || !(editAllowed)}>
</Button>)} </Button>)}
{showActions && retryAllowed && ( {showActions && retryAllowed && (
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
onClick={onRetry} onClick={onRetry}
disabled={retryDisabled === true}> disabled={retryDisabled === true}>
</Button>)} </Button>)}
</div> </div>
</div> </div>
</div> </div>
<div <div
className={cn ( className={cn (
'space-y-3 rounded-lg border p-4 md:hidden', 'space-y-3 rounded-lg border p-4 md:hidden',
'transition-shadow hover:shadow-sm')}> 'transition-shadow hover:shadow-sm')}>
<div className="text-sm font-medium">#{rowNumber}</div> <div className="text-sm font-medium">#{rowNumber}</div>
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<PostImportThumbnailPreview <PostImportThumbnailPreview
url={String (row.attributes.thumbnailBase ?? '')} url={String (row.attributes.thumbnailBase ?? '')}
file={row.thumbnailFile} file={row.thumbnailFile}
className="h-20 w-20 shrink-0"/> className="h-20 w-20 shrink-0"/>
<div className="min-w-0 flex-1 space-y-2"> <div className="min-w-0 flex-1 space-y-2">
<div className="line-clamp-2 text-sm font-medium"> <div className="line-clamp-2 text-sm font-medium">
{String (row.attributes.title ?? '')} {String (row.attributes.title ?? '')}
</div> </div>
<div className="truncate text-xs text-neutral-600 dark:text-neutral-300"> <div className="truncate text-xs text-neutral-600 dark:text-neutral-300">
{row.url} {row.url}
</div> </div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{displayStatus != null && <PostImportStatusBadge value={displayStatus}/>} {displayStatus != null && <PostImportStatusBadge value={displayStatus}/>}
</div> </div>
<PostImportTagLinks tags={row.displayTags}/> <PostImportTagLinks tags={row.displayTags}/>
<div className="text-xs text-neutral-500 dark:text-neutral-400"> <div className="text-xs text-neutral-500 dark:text-neutral-400">
{summaryDate (row)} {summaryDate (row)}
</div> </div>
{showDuration && ( {showDuration && (
<div className="text-xs text-neutral-500 dark:text-neutral-400"> <div className="text-xs text-neutral-500 dark:text-neutral-400">
{duration} {duration}
</div>)} </div>)}
{warning && ( {warning && (
<div className="text-xs text-amber-700 dark:text-amber-200"> <div className="text-xs text-amber-700 dark:text-amber-200">
{warning} {warning}
</div>)} </div>)}
<FieldError messages={rowMessages}/> <FieldError messages={rowMessages}/>
{skipControl} {skipControl}
</div> </div>
</div> </div>
{showActions && (editVisible || retryAllowed) && ( {showActions && (editVisible || retryAllowed) && (
<div className="flex flex-col gap-2 md:flex-row"> <div className="flex flex-col gap-2 md:flex-row">
{editVisible && ( {editVisible && (
<Button <Button
type="button" type="button"
className="w-full md:w-auto" className="w-full md:w-auto"
variant="outline" variant="outline"
onClick={onEdit} onClick={onEdit}
disabled={editDisabled === true || !(editAllowed)}> disabled={editDisabled === true || !(editAllowed)}>
</Button>)} </Button>)}
{retryAllowed && ( {retryAllowed && (
<Button <Button
type="button" type="button"
className="w-full md:w-auto" className="w-full md:w-auto"
variant="outline" variant="outline"
onClick={onRetry} onClick={onRetry}
disabled={retryDisabled === true}> disabled={retryDisabled === true}>
</Button>)} </Button>)}
</div>)} </div>)}
</div> </div>
</>) </>)
} }
+2 -1
ファイルの表示
@@ -12,7 +12,7 @@ const PostImportTagLinks: FC<Props> = ({ tags }) => {
return null return null
return ( return (
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap text-xs gap-x-1">
{tags.map (tag => { {tags.map (tag => {
const key = `${ tag.category }:${ tag.name }:${ tag.sectionLiterals?.join ('|') ?? '' }` const key = `${ tag.category }:${ tag.name }:${ tag.sectionLiterals?.join ('|') ?? '' }`
return ( return (
@@ -21,6 +21,7 @@ const PostImportTagLinks: FC<Props> = ({ tags }) => {
tag={{ tag={{
name: tag.name, name: tag.name,
category: tag.category }} category: tag.category }}
linkFlg={false}
withWiki={false} withWiki={false}
withCount={false}/> withCount={false}/>
{tag.sectionLiterals?.map (literal => ( {tag.sectionLiterals?.map (literal => (
+32 -57
ファイルの表示
@@ -1,14 +1,8 @@
import { render, screen, waitFor } from '@testing-library/react' import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import PostImportThumbnailPreview from '@/components/posts/import/PostImportThumbnailPreview' import PostImportThumbnailPreview from '@/components/posts/import/PostImportThumbnailPreview'
const api = vi.hoisted (() => ({
apiGet: vi.fn (),
}))
vi.mock ('@/lib/api', () => api)
describe ('PostImportThumbnailPreview', () => { describe ('PostImportThumbnailPreview', () => {
beforeEach (() => { beforeEach (() => {
vi.clearAllMocks () vi.clearAllMocks ()
@@ -16,69 +10,50 @@ describe ('PostImportThumbnailPreview', () => {
globalThis.URL.revokeObjectURL = vi.fn () globalThis.URL.revokeObjectURL = vi.fn ()
}) })
it ('uses a backend-fetched blob URL instead of the external thumbnail URL directly', async () => { it ('renders the remote URL directly without a backend proxy', () => {
api.apiGet.mockResolvedValueOnce (new Blob (['img'], { type: 'image/png' }))
render ( render (
<PostImportThumbnailPreview <PostImportThumbnailPreview
url="https://example.com/thumbnail.jpg" url="https://example.com/thumbnail.jpg"
className="h-10 w-10"/>) className="h-10 w-10"/>)
await waitFor (() => { expect (screen.getByRole ('img')).toHaveAttribute (
expect (screen.getByRole ('img')).toHaveAttribute ('src', 'blob:preview') 'src',
}) 'https://example.com/thumbnail.jpg')
expect (screen.getByRole ('img')).not.toHaveAttribute ( expect (screen.getByRole ('img')).toHaveAttribute (
'src', 'referrerpolicy',
'https://example.com/thumbnail.jpg') 'no-referrer')
expect (api.apiGet).toHaveBeenCalledWith ('/preview/thumbnail', {
params: { url: 'https://example.com/thumbnail.jpg' },
responseType: 'blob' })
}) })
it ('does not render the unsafe URL directly when preview fetching fails', async () => { it ('shows the empty frame after the remote image fails', () => {
api.apiGet.mockRejectedValueOnce (new Error ('unsafe'))
const { container } = render ( const { container } = render (
<PostImportThumbnailPreview <PostImportThumbnailPreview
url="http://127.0.0.1/private.png" url="https://example.com/missing.jpg"
className="h-10 w-10"/>) className="h-10 w-10"/>)
await waitFor (() => { fireEvent.error (screen.getByRole ('img'))
expect (screen.queryByRole ('img')).toBeNull ()
}) expect (screen.queryByRole ('img')).toBeNull ()
expect (screen.queryByText ('サムネールを表示できません')).toBeNull ()
expect (screen.queryByText ('なし')).toBeNull ()
expect (container.querySelector ('div.rounded.border.bg-muted')).not.toBeNull () expect (container.querySelector ('div.rounded.border.bg-muted')).not.toBeNull ()
expect (container.textContent).toBe ('') expect (container.textContent).toBe ('')
expect (screen.queryByRole ('img')).toBeNull ()
}) })
it ('revokes the old object URL when the source URL changes', async () => { it ('uses and revokes an object URL only when the remote URL is blank', () => {
const createObjectUrlMock = const file = new File (['image'], 'thumbnail.png', { type: 'image/png' })
globalThis.URL.createObjectURL as unknown as ReturnType<typeof vi.fn> const { rerender, unmount } = render (
createObjectUrlMock <PostImportThumbnailPreview url="" file={file} className="h-10 w-10"/>)
.mockReturnValueOnce ('blob:first')
.mockReturnValueOnce ('blob:second')
api.apiGet
.mockResolvedValueOnce (new Blob (['first'], { type: 'image/png' }))
.mockResolvedValueOnce (new Blob (['second'], { type: 'image/png' }))
const { rerender } = render ( expect (screen.getByRole ('img')).toHaveAttribute ('src', 'blob:preview')
<PostImportThumbnailPreview
url="https://example.com/first.jpg"
className="h-10 w-10"/>)
await waitFor (() => {
expect (screen.getByRole ('img')).toHaveAttribute ('src', 'blob:first')
})
rerender ( rerender (
<PostImportThumbnailPreview <PostImportThumbnailPreview
url="https://example.com/second.jpg" url="https://example.com/remote.jpg"
className="h-10 w-10"/>) file={file}
className="h-10 w-10"/>)
expect (screen.getByRole ('img')).toHaveAttribute (
'src',
'https://example.com/remote.jpg')
await waitFor (() => { unmount ()
expect (screen.getByRole ('img')).toHaveAttribute ('src', 'blob:second') expect (globalThis.URL.revokeObjectURL).toHaveBeenCalledWith ('blob:preview')
})
expect (globalThis.URL.revokeObjectURL).toHaveBeenCalledWith ('blob:first')
}) })
}) })
+4 -4
ファイルの表示
@@ -16,14 +16,14 @@ describe ('displayPostImportStatus', () => {
skipReason: 'manual' }))).toBe ('skipped') skipReason: 'manual' }))).toBe ('skipped')
}) })
it ('does not expose validation, failure, or created states as badges', () => { it ('distinguishes validation, failure, and created states', () => {
expect (displayPostImportStatus (buildPostImportRow ({ expect (displayPostImportStatus (buildPostImportRow ({
status: 'error', status: 'error',
validationErrors: { title: ['invalid'] } }))).toBeNull () validationErrors: { title: ['invalid'] } }))).toBe ('error')
expect (displayPostImportStatus (buildPostImportRow ({ expect (displayPostImportStatus (buildPostImportRow ({
importStatus: 'failed' }))).toBeNull () importStatus: 'failed' }))).toBe ('failed')
expect (displayPostImportStatus (buildPostImportRow ({ expect (displayPostImportStatus (buildPostImportRow ({
importStatus: 'created', importStatus: 'created',
createdPostId: 3 }))).toBeNull () createdPostId: 3 }))).toBe ('created')
}) })
}) })
+1 -1
ファイルの表示
@@ -1,4 +1,4 @@
import type { PostImportRow } from '@/lib/postImportSession' import type { PostImportRow } from '@/lib/postImportTypes'
export type PostImportDisplayStatus = export type PostImportDisplayStatus =
'ready' 'ready'
+95 -97
ファイルの表示
@@ -1,10 +1,10 @@
"use client" 'use client'
import * as React from "react" import * as DialogPrimitive from '@radix-ui/react-dialog'
import * as DialogPrimitive from "@radix-ui/react-dialog" import { X } from 'lucide-react'
import { X } from "lucide-react" import * as React from 'react'
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils'
const Dialog = DialogPrimitive.Root const Dialog = DialogPrimitive.Root
@@ -15,111 +15,109 @@ const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef< const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>, React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay> React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => ( >(
<DialogPrimitive.Overlay ({ className, ...props }, ref) => (
ref={ref} <DialogPrimitive.Overlay
className={cn( ref={ref}
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0", className={cn (
className)} 'fixed inset-0 z-50 bg-black/80',
{...props} 'data-[state=open]:animate-in data-[state=closed]:animate-out',
/>)) 'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className)}
{...props}/>))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef< const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>, React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => ( >(
<DialogPortal> ({ className, children, ...props }, ref) => (
<DialogOverlay /> <DialogPortal>
<DialogPrimitive.Content <DialogOverlay />
ref={ref} <DialogPrimitive.Content
className={cn ( ref={ref}
'fixed left-[50%] top-[50%] z-50 grid w-[calc(100%-2rem)] max-w-lg', className={cn (
'translate-x-[-50%] translate-y-[-50%]', 'fixed left-[50%] top-[50%] z-50 grid w-[calc(100%-2rem)] max-w-lg',
'gap-5 rounded-2xl border border-border', 'translate-x-[-50%] translate-y-[-50%]',
'bg-background p-6 text-foreground shadow-2xl', 'gap-5 rounded-2xl border border-border',
'duration-200', 'bg-background p-6 text-foreground shadow-2xl',
'data-[state=open]:animate-in data-[state=closed]:animate-out', 'duration-200',
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0', 'data-[state=open]:animate-in data-[state=closed]:animate-out',
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95', 'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className)} 'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
{...props} className)}
> {...props}>
{children} {children}
<DialogPrimitive.Close <DialogPrimitive.Close
className={cn ( className={cn (
'absolute left-4 top-4 rounded-full p-1', 'absolute left-4 top-4 rounded-full p-1',
'text-slate-500 transition-colors', 'text-slate-500 transition-colors',
'hover:bg-slate-200 hover:text-slate-900', 'hover:bg-slate-200 hover:text-slate-900',
'dark:text-slate-400 dark:hover:bg-slate-700 dark:hover:text-slate-50', 'dark:text-slate-400 dark:hover:bg-slate-700 dark:hover:text-slate-50',
'focus:outline-none focus:ring-2 focus:ring-slate-400')}> 'focus:outline-none focus:ring-2 focus:ring-slate-400')}>
<X className="h-4 w-4"/> <X className="h-4 w-4"/>
<span className="sr-only"></span> <span className="sr-only"></span>
</DialogPrimitive.Close> </DialogPrimitive.Close>
</DialogPrimitive.Content> </DialogPrimitive.Content>
</DialogPortal>)) </DialogPortal>))
DialogContent.displayName = DialogPrimitive.Content.displayName DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({ const DialogHeader = (
className, { className, ...props }: React.HTMLAttributes<HTMLDivElement>,
...props ) => (
}: React.HTMLAttributes<HTMLDivElement>) => ( <div
<div className={cn (
className={cn( 'flex flex-col space-y-1.5 text-center md:text-left',
"flex flex-col space-y-1.5 text-center md:text-left", className)}
className)} {...props}/>)
{...props} DialogHeader.displayName = 'DialogHeader'
/>)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({ const DialogFooter = (
className, { className, ...props }: React.HTMLAttributes<HTMLDivElement>,
...props ) => (
}: React.HTMLAttributes<HTMLDivElement>) => ( <div
<div className={cn (
className={cn( 'flex flex-col-reverse md:flex-row md:justify-end md:space-x-2',
"flex flex-col-reverse md:flex-row md:justify-end md:space-x-2", className)}
className)} {...props}/>)
{...props} DialogFooter.displayName = 'DialogFooter'
/>)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef< const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>, React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title> React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => ( >(
<DialogPrimitive.Title ({ className, ...props }, ref) => (
ref={ref} <DialogPrimitive.Title
className={cn( ref={ref}
"text-lg font-semibold leading-none tracking-tight", className={cn (
className)} 'text-lg font-semibold leading-none tracking-tight',
{...props} className)}
/>)) {...props}/>))
DialogTitle.displayName = DialogPrimitive.Title.displayName DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef< const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>, React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description> React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => ( >(
<DialogPrimitive.Description ({ className, ...props }, ref) => (
ref={ref} <DialogPrimitive.Description
className={cn("text-sm text-muted-foreground", className)} ref={ref}
{...props} className={cn ('text-sm text-muted-foreground', className)}
/>)) {...props}/>))
DialogDescription.displayName = DialogPrimitive.Description.displayName DialogDescription.displayName = DialogPrimitive.Description.displayName
export { export {
Dialog, Dialog,
DialogPortal, DialogPortal,
DialogOverlay, DialogOverlay,
DialogClose, DialogClose,
DialogTrigger, DialogTrigger,
DialogContent, DialogContent,
DialogHeader, DialogHeader,
DialogFooter, DialogFooter,
DialogTitle, DialogTitle,
DialogDescription, DialogDescription,
} }
+2 -1
ファイルの表示
@@ -16,7 +16,7 @@ import { creatableImportRows,
resultRowMessages, resultRowMessages,
resultSummaryCounts, resultSummaryCounts,
retryImportRow, retryImportRow,
reviewSummaryCounts } from '@/lib/postImportSession' reviewSummaryCounts } from '@/lib/postImportRows'
import { buildPostImportRow } from '@/test/postImportFactories' import { buildPostImportRow } from '@/test/postImportFactories'
describe ('post import row state', () => { describe ('post import row state', () => {
@@ -274,6 +274,7 @@ describe ('post import row state', () => {
thumbnailBase: '', thumbnailBase: '',
originalCreatedFrom: '', originalCreatedFrom: '',
originalCreatedBefore: '', originalCreatedBefore: '',
duration: '2',
tags: 'edited-tag', tags: 'edited-tag',
parentPostIds: '' }, parentPostIds: '' },
true) true)
-5
ファイルの表示
@@ -226,11 +226,6 @@ export const buildNextEditedRow = (
? 'manual' ? 'manual'
: (editingRow.provenance[field] ?? 'automatic') : (editingRow.provenance[field] ?? 'automatic')
}) })
if (nextProvenance.duration === 'manual')
{
nextAttributes.videoMs = ''
nextProvenance.videoMs = 'manual'
}
nextAttributes.tags = draft.tags nextAttributes.tags = draft.tags
if (isManualChange (editingRow.attributes.tags, draft.tags)) if (isManualChange (editingRow.attributes.tags, draft.tags))
{ {
-4
ファイルの表示
@@ -1,4 +0,0 @@
export * from '@/lib/postImportTypes'
export * from '@/lib/postImportStorage'
export * from '@/lib/postImportSourceValidation'
export * from '@/lib/postImportRows'
+4 -1
ファイルの表示
@@ -1,6 +1,9 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { countImportSourceLines, validateImportSource } from '@/lib/postImportSession' import {
countImportSourceLines,
validateImportSource,
} from '@/lib/postImportSourceValidation'
describe ('post import source validation', () => { describe ('post import source validation', () => {
it ('counts trimmed non-empty CRLF and LF rows', () => { it ('counts trimmed non-empty CRLF and LF rows', () => {
+15 -108
ファイルの表示
@@ -1,124 +1,31 @@
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import { cleanupExpiredPostImportSessions, import {
clearPostImportSourceDraft, clearPostImportSourceDraft,
loadPostImportSession, loadPostImportSourceDraft,
loadPostImportSourceDraft, savePostImportSourceDraft,
savePostImportSession, } from '@/lib/postImportStorage'
savePostImportSourceDraft } from '@/lib/postImportSession'
import { buildPostImportRow } from '@/test/postImportFactories'
describe ('post import storage', () => { describe ('post import source draft storage', () => {
beforeEach (() => { beforeEach (() => {
sessionStorage.clear () sessionStorage.clear ()
vi.useRealTimers () vi.restoreAllMocks ()
}) })
it ('round-trips a valid session and source draft', () => { it ('round-trips and clears the URL list source draft', () => {
const row = buildPostImportRow ({
attributes: { duration: '2.5' },
recoverable: true,
importStatus: 'pending',
validationErrors: { title: ['invalid'] } })
const skipped = buildPostImportRow ({
importStatus: 'skipped',
skipReason: 'existing',
existingPostId: 10 })
const manual = buildPostImportRow ({
sourceRow: 3,
skipReason: 'manual' })
expect (savePostImportSession ({
source: skipped.url,
rows: [row, skipped, manual],
repairMode: 'all' })).toBe (true)
expect (loadPostImportSession ()).toMatchObject ({
version: 2,
source: skipped.url,
rows: [{
attributes: { duration: '2.5' },
recoverable: true,
importStatus: 'pending' },
{
importStatus: 'skipped',
skipReason: 'existing',
existingPostId: 10 },
{
skipReason: 'manual' }] })
expect (savePostImportSourceDraft ('https://example.com')).toBe (true) expect (savePostImportSourceDraft ('https://example.com')).toBe (true)
expect (loadPostImportSourceDraft ()).toEqual ({ source: 'https://example.com' }) expect (loadPostImportSourceDraft ()).toEqual ({
source: 'https://example.com' })
clearPostImportSourceDraft () clearPostImportSourceDraft ()
expect (loadPostImportSourceDraft ()).toEqual ({ source: '' }) expect (loadPostImportSourceDraft ()).toEqual ({ source: '' })
}) })
it ('rejects inconsistent post IDs and terminal statuses', () => { it ('ignores malformed stored drafts', () => {
const session = { sessionStorage.setItem ('post-import-source-draft', '{')
version: 2,
savedAt: new Date ().toISOString (),
source: '',
repairMode: 'all',
rows: [buildPostImportRow ()] }
const invalidRows = [
{ ...session.rows[0], skipReason: 'existing', existingPostId: undefined },
{ ...session.rows[0], existingPostId: 2, skipReason: undefined },
{ ...session.rows[0], importStatus: 'created', createdPostId: undefined },
{ ...session.rows[0], importStatus: 'failed', createdPostId: 3 },
{ ...session.rows[0], importStatus: 'created', recoverable: true },
{ ...session.rows[0], importStatus: 'skipped', recoverable: true },
{ ...session.rows[0], recoverable: true }]
for (const row of invalidRows) expect (loadPostImportSourceDraft ()).toEqual ({ source: '' })
{
sessionStorage.setItem ('post-import-session:current', JSON.stringify ({
...session,
rows: [row] }))
expect (loadPostImportSession ()).toBeNull ()
sessionStorage.clear ()
}
})
it ('rejects invalid attribute, provenance, tag-source, and snapshot data', () => {
const row = buildPostImportRow ()
const invalidRows = [
{ ...row, attributes: { title: [] } },
{ ...row, attributes: { unknown: 'value' } },
{ ...row, provenance: { title: 'mapped' } },
{ ...row, tagSources: { mapped: 'tag' } },
{ ...row, resetSnapshot: { ...row.resetSnapshot,
fieldWarnings: { title: 'warning' } } }]
invalidRows.forEach (invalidRow => {
sessionStorage.setItem ('post-import-session:current', JSON.stringify ({
version: 2,
savedAt: new Date ().toISOString (),
source: '',
rows: [invalidRow],
repairMode: 'all' }))
expect (loadPostImportSession ()).toBeNull ()
sessionStorage.clear ()
})
})
it ('keeps only the current fixed-key session and removes legacy prefixed entries', () => {
const current = {
version: 2,
savedAt: new Date ().toISOString (),
source: '',
rows: [buildPostImportRow ()],
repairMode: 'all' }
const expired = {
...current,
savedAt: new Date (Date.now () - 25 * 60 * 60 * 1000).toISOString () }
sessionStorage.setItem ('post-import-session:current', JSON.stringify (current))
sessionStorage.setItem ('post-import-session:expired', JSON.stringify (expired))
sessionStorage.setItem ('post-import-session:malformed', '{')
cleanupExpiredPostImportSessions ()
expect (sessionStorage.getItem ('post-import-session:current')).not.toBeNull ()
expect (sessionStorage.getItem ('post-import-session:expired')).toBeNull ()
expect (sessionStorage.getItem ('post-import-session:malformed')).toBeNull ()
}) })
it ('reports storage access failures without throwing', () => { it ('reports storage access failures without throwing', () => {
+43
ファイルの表示
@@ -0,0 +1,43 @@
import { describe, expect, it } from 'vitest'
import {
buildPostNewReviewPath,
hasPostNewReviewState,
isPostNewReviewPathWithinLimit,
parsePostNewReviewUrls,
postNewReviewPathByteLength,
} from '@/lib/postNewQueryState'
describe ('post new review URL state', () => {
it ('encodes each URL separately and preserves literal plus signs', () => {
const urls = [
'https://example.com/one+a',
'https://example.com/two?value=b+c']
const path = buildPostNewReviewPath (urls)
expect (path).toBe (
'/posts/new?urls=https%3A%2F%2Fexample.com%2Fone%2Ba'
+ '+https%3A%2F%2Fexample.com%2Ftwo%3Fvalue%3Db%2Bc')
expect (parsePostNewReviewUrls (path.slice ('/posts/new'.length))).toEqual (urls)
})
it ('uses only the raw urls parameter as review state', () => {
expect (hasPostNewReviewState ('?session_id=old&meta=old')).toBe (false)
expect (hasPostNewReviewState ('?unknown=value&urls=')).toBe (true)
expect (parsePostNewReviewUrls ('?unknown=value&urls=one+two&meta=old'))
.toEqual (['one', 'two'])
})
it ('allows at most a 6 143 byte request target', () => {
const baseUrl = 'https://example.com/'
const baseLength = postNewReviewPathByteLength ([baseUrl])
const allowed = `${ baseUrl }${ 'a'.repeat (6_143 - baseLength) }`
const denied = `${ allowed }a`
expect (postNewReviewPathByteLength ([allowed])).toBe (6_143)
expect (isPostNewReviewPathWithinLimit ([allowed])).toBe (true)
expect (postNewReviewPathByteLength ([denied])).toBe (6_144)
expect (isPostNewReviewPathWithinLimit ([denied])).toBe (false)
})
})
+1 -1
ファイルの表示
@@ -1,5 +1,5 @@
const POST_NEW_REVIEW_PATH_PREFIX = '/posts/new?urls=' const POST_NEW_REVIEW_PATH_PREFIX = '/posts/new?urls='
const MAX_POST_NEW_REVIEW_TARGET_BYTES = 4096 const MAX_POST_NEW_REVIEW_TARGET_BYTES = 6_144
const textEncoder = new TextEncoder () const textEncoder = new TextEncoder ()
+2 -7
ファイルの表示
@@ -25,7 +25,6 @@ import {
getEffectiveKeyBindings, getEffectiveKeyBindings,
setClientKeyboardSettings, setClientKeyboardSettings,
} from '@/lib/settings' } from '@/lib/settings'
import { useUnsavedChangesGuard } from '@/lib/useUnsavedChangesGuard'
import type { import type {
KeyBinding, KeyBinding,
@@ -97,7 +96,6 @@ const focusSearchTarget = (): void => {
export const KeyboardShortcutsProvider = ({ children }: PropsWithChildren) => { export const KeyboardShortcutsProvider = ({ children }: PropsWithChildren) => {
const location = useLocation () const location = useLocation ()
const navigate = useNavigate () const navigate = useNavigate ()
const { confirmDiscardNavigation } = useUnsavedChangesGuard ()
const [keyboardSettings, setKeyboardSettingsState] = const [keyboardSettings, setKeyboardSettingsState] =
useState<ClientKeyboardSettings> (() => getClientKeyboardSettings ()) useState<ClientKeyboardSettings> (() => getClientKeyboardSettings ())
@@ -149,11 +147,8 @@ export const KeyboardShortcutsProvider = ({ children }: PropsWithChildren) => {
}, []) }, [])
const guardedNavigate = useCallback ((path: string) => { const guardedNavigate = useCallback ((path: string) => {
confirmDiscardNavigation ().then (confirmed => { navigate (path)
if (confirmed) }, [navigate])
navigate (path)
})
}, [confirmDiscardNavigation, navigate])
const builtinHandlers = useMemo<ShortcutHandlers> ( const builtinHandlers = useMemo<ShortcutHandlers> (
() => ({ () => ({
+77
ファイルの表示
@@ -0,0 +1,77 @@
import { fireEvent, screen, waitFor, within } from '@testing-library/react'
import { useState } from 'react'
import { useLocation, useNavigate } from 'react-router-dom'
import { describe, expect, it, vi } from 'vitest'
import { useUnsavedChangesGuard } from '@/lib/useUnsavedChangesGuard'
import { renderWithProviders } from '@/test/render'
const GuardHarness = () => {
const [dirty, setDirty] = useState (true)
const location = useLocation ()
const navigate = useNavigate ()
const discard = vi.fn (() => setDirty (false))
const { allowNextNavigation } = useUnsavedChangesGuard ({
dirty,
onDiscard: discard })
return (
<>
<output aria-label="location">{location.pathname}{location.search}</output>
<button type="button" onClick={() => navigate ('/next?tab=one')}>move</button>
<button
type="button"
onClick={() => {
allowNextNavigation ()
navigate ('/allowed')
}}>
allowed move
</button>
</>)
}
describe ('useUnsavedChangesGuard', () => {
it ('blocks route changes and resets a cancelled transition', async () => {
renderWithProviders (<GuardHarness/>, { route: '/current' })
fireEvent.click (screen.getByRole ('button', { name: 'move' }))
expect (within (await screen.findByRole ('dialog')).getByText (
'変更が破棄してページ移動しますか?')).toBeInTheDocument ()
expect (screen.getByLabelText ('location')).toHaveTextContent ('/current')
fireEvent.click (screen.getByRole ('button', { name: '取消' }))
await waitFor (() => {
expect (screen.queryByRole ('dialog')).not.toBeInTheDocument ()
})
expect (screen.getByLabelText ('location')).toHaveTextContent ('/current')
})
it ('proceeds with the blocked transition after discard is confirmed', async () => {
renderWithProviders (<GuardHarness/>, { route: '/current' })
fireEvent.click (screen.getByRole ('button', { name: 'move' }))
fireEvent.click (await screen.findByRole ('button', {
name: '変更を破棄して移動' }))
await waitFor (() => {
expect (screen.getByLabelText ('location')).toHaveTextContent ('/next?tab=one')
})
})
it ('allows only the next navigation without leaving a bypass token', async () => {
renderWithProviders (<GuardHarness/>, { route: '/current' })
fireEvent.click (screen.getByRole ('button', { name: 'allowed move' }))
await waitFor (() => {
expect (screen.getByLabelText ('location')).toHaveTextContent ('/allowed')
})
fireEvent.click (screen.getByRole ('button', { name: 'move' }))
expect (within (await screen.findByRole ('dialog')).getByText (
'変更が破棄してページ移動しますか?')).toBeInTheDocument ()
expect (screen.getByLabelText ('location')).toHaveTextContent ('/allowed')
})
})
+112 -61
ファイルの表示
@@ -7,7 +7,7 @@ import {
useRef, useRef,
useState, useState,
} from 'react' } from 'react'
import { useBlocker, useLocation } from 'react-router-dom' import { useBlocker } from 'react-router-dom'
import { useDialogue } from '@/lib/dialogues/useDialogue' import { useDialogue } from '@/lib/dialogues/useDialogue'
@@ -22,12 +22,11 @@ type UseUnsavedChangesGuardOptions = {
onDiscard?: () => void | Promise<void> } onDiscard?: () => void | Promise<void> }
type UnsavedChangesGuardContextValue = { type UnsavedChangesGuardContextValue = {
hasUnsavedChanges: boolean hasUnsavedChanges: boolean
registerUnsavedChangesSource: ( registerUnsavedChangesSource: (
source: UnsavedChangesSource | null, source: UnsavedChangesSource,
) => void ) => () => void
confirmDiscardNavigation: () => Promise<boolean> allowNextNavigation: () => void }
allowNextNavigation: () => void }
const UnsavedChangesGuardContext = const UnsavedChangesGuardContext =
createContext<UnsavedChangesGuardContextValue | null> (null) createContext<UnsavedChangesGuardContextValue | null> (null)
@@ -35,67 +34,123 @@ const UnsavedChangesGuardContext =
export const UnsavedChangesGuardProvider: FC<PropsWithChildren> = ({ children }) => { export const UnsavedChangesGuardProvider: FC<PropsWithChildren> = ({ children }) => {
const dialogue = useDialogue () const dialogue = useDialogue ()
const location = useLocation () const sourcesRef = useRef (new Map<symbol, UnsavedChangesSource> ())
const [source, setSource] = useState<UnsavedChangesSource | null> (null) const bypassNextNavigationRef = useRef<symbol | null> (null)
const bypassNextNavigationRef = useRef (false) const [revision, setRevision] = useState (0)
const dialogueOpenRef = useRef (false)
const handlingBlockedTransitionRef = useRef (false)
const registerUnsavedChangesSource = useCallback (( const registerUnsavedChangesSource = useCallback ((
nextSource: UnsavedChangesSource | null, source: UnsavedChangesSource,
) => { ): (() => void) => {
setSource (nextSource) const sourceId = Symbol ('unsaved-changes-source')
sourcesRef.current.set (sourceId, source)
setRevision (current => current + 1)
return () => {
if (!(sourcesRef.current.delete (sourceId)))
return
setRevision (current => current + 1)
}
}, []) }, [])
const allowNextNavigation = useCallback (() => { const allowNextNavigation = useCallback (() => {
bypassNextNavigationRef.current = true const token = Symbol ('allowed-navigation')
bypassNextNavigationRef.current = token
queueMicrotask (() => {
if (bypassNextNavigationRef.current === token)
bypassNextNavigationRef.current = null
})
}, []) }, [])
const confirmDiscardNavigation = useCallback (async (): Promise<boolean> => { const sources = useMemo (
if (!(source?.dirty)) () => [...sourcesRef.current.values ()],
[revision],
)
const dirtySources = useMemo (
() => sources.filter (source => source.dirty),
[sources],
)
const hasUnsavedChanges = dirtySources.length > 0
const hasUnsavedChangesRef = useRef (hasUnsavedChanges)
hasUnsavedChangesRef.current = hasUnsavedChanges
const shouldBlock = useCallback (() => {
if (bypassNextNavigationRef.current != null)
{
bypassNextNavigationRef.current = null
return false
}
return hasUnsavedChangesRef.current
}, [])
const blocker = useBlocker (shouldBlock)
const confirmDiscardChanges = useCallback (async (): Promise<boolean> => {
if (!(hasUnsavedChanges))
return true return true
const confirmed = await dialogue.confirm ({ if (dialogueOpenRef.current)
title: '変更が破棄してページ移動しますか?',
confirmText: '変更を破棄して移動',
variant: 'danger' })
if (!(confirmed))
return false return false
bypassNextNavigationRef.current = true dialogueOpenRef.current = true
await source.discard ()
return true
}, [dialogue, source])
const blocker = useBlocker (() => try
source?.dirty === true && !(bypassNextNavigationRef.current)) {
const confirmed = await dialogue.confirm ({
title: '変更が破棄してページ移動しますか?',
confirmText: '変更を破棄して移動',
variant: 'danger' })
if (!(confirmed))
return false
return true
}
finally
{
dialogueOpenRef.current = false
}
}, [dialogue, hasUnsavedChanges])
useEffect (() => { useEffect (() => {
if (blocker.state !== 'blocked') if (blocker.state !== 'blocked' || handlingBlockedTransitionRef.current)
return return
let cancelled = false handlingBlockedTransitionRef.current = true
void (async () => { void (async () => {
const confirmed = await confirmDiscardNavigation () try
if (cancelled) {
return const confirmed = await confirmDiscardChanges ()
if (!(confirmed))
{
blocker.reset ()
return
}
if (confirmed) let discardResults: Array<void | Promise<void>>
blocker.proceed () try
else {
blocker.reset () discardResults = dirtySources.map (source => source.discard ())
}
catch
{
blocker.reset ()
return
}
blocker.proceed ()
await Promise.allSettled (discardResults)
}
finally
{
handlingBlockedTransitionRef.current = false
}
}) () }) ()
}, [blocker, confirmDiscardChanges, dirtySources])
return () => {
cancelled = true
}
}, [blocker, confirmDiscardNavigation])
useEffect (() => { useEffect (() => {
bypassNextNavigationRef.current = false if (!(hasUnsavedChanges))
}, [location.hash, location.pathname, location.search])
useEffect (() => {
if (!(source?.dirty))
return return
const handleBeforeUnload = (event: BeforeUnloadEvent) => { const handleBeforeUnload = (event: BeforeUnloadEvent) => {
@@ -107,23 +162,21 @@ export const UnsavedChangesGuardProvider: FC<PropsWithChildren> = ({ children })
return () => { return () => {
window.removeEventListener ('beforeunload', handleBeforeUnload) window.removeEventListener ('beforeunload', handleBeforeUnload)
} }
}, [source?.dirty]) }, [hasUnsavedChanges])
const value = useMemo<UnsavedChangesGuardContextValue> (() => ({ const value = useMemo<UnsavedChangesGuardContextValue> (() => ({
hasUnsavedChanges: source?.dirty === true, hasUnsavedChanges,
registerUnsavedChangesSource, registerUnsavedChangesSource,
confirmDiscardNavigation,
allowNextNavigation, allowNextNavigation,
}), [ }), [
allowNextNavigation, allowNextNavigation,
confirmDiscardNavigation, hasUnsavedChanges,
registerUnsavedChangesSource, registerUnsavedChangesSource,
source?.dirty,
]) ])
return ( return (
<UnsavedChangesGuardContext.Provider value={value}> <UnsavedChangesGuardContext.Provider value={value}>
{children} {children}
</UnsavedChangesGuardContext.Provider>) </UnsavedChangesGuardContext.Provider>)
} }
@@ -136,18 +189,16 @@ export const useUnsavedChangesGuard = (
if (context == null) if (context == null)
throw new Error ('UnsavedChangesGuardProvider が必要です.') throw new Error ('UnsavedChangesGuardProvider が必要です.')
const { registerUnsavedChangesSource } = context
useEffect (() => { useEffect (() => {
if (options == null) if (options == null)
return return
context.registerUnsavedChangesSource ({ return registerUnsavedChangesSource ({
dirty: options.dirty, dirty: options.dirty,
discard: options.onDiscard ?? (() => undefined) }) discard: options.onDiscard ?? (() => undefined) })
}, [options?.dirty, options?.onDiscard, registerUnsavedChangesSource])
return () => {
context.registerUnsavedChangesSource (null)
}
}, [context, options?.dirty, options?.onDiscard])
return context return context
} }
+30 -27
ファイルの表示
@@ -13,7 +13,7 @@ import MainArea from '@/components/layout/MainArea'
import { SITE_TITLE } from '@/config' import { SITE_TITLE } from '@/config'
import { fetchMaterials, parseMaterialFilter } from '@/lib/materials' import { fetchMaterials, parseMaterialFilter } from '@/lib/materials'
import { materialsKeys } from '@/lib/queryKeys' import { materialsKeys } from '@/lib/queryKeys'
import { dateString, inputClass } from '@/lib/utils' import { cn, dateString, inputClass } from '@/lib/utils'
import type { FC, FormEvent } from 'react' import type { FC, FormEvent } from 'react'
@@ -113,10 +113,11 @@ const clearedTagSelectionPath = (
const MaterialThumb: FC<{ material: Material }> = ({ material }) => ( const MaterialThumb: FC<{ material: Material }> = ({ material }) => (
<div <div
className={`flex aspect-square h-[180px] w-[180px] items-center justify-center className={cn (
overflow-hidden rounded-lg border border-stone-200 bg-white text-center 'flex aspect-square h-[180px] w-[180px] items-center justify-center',
text-stone-900 shadow-sm dark:border-stone-700 dark:bg-stone-900 'overflow-hidden rounded-lg border border-stone-200 bg-white text-center',
dark:text-stone-100`}> 'text-stone-900 shadow-sm dark:border-stone-700 dark:bg-stone-900',
'dark:text-stone-100')}>
{material.thumbnail {material.thumbnail
? <img src={material.thumbnail} alt="" className="block h-full w-full object-cover"/> ? <img src={material.thumbnail} alt="" className="block h-full w-full object-cover"/>
: ( : (
@@ -531,30 +532,32 @@ const MaterialListPage: FC = () => {
<div className="flex flex-wrap items-center justify-between gap-3"> <div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<button <button
type="button" type="button"
onClick={() => updateQuery ({ view: 'card' })} onClick={() => updateQuery ({ view: 'card' })}
className={`rounded-full border px-4 py-2 text-sm ${ className={cn (
view === 'card' 'rounded-full border px-4 py-2 text-sm',
? [ view === 'card'
'border-sky-500 bg-sky-50 text-sky-700 dark:border-sky-400', ? [
'dark:bg-sky-950 dark:text-sky-100'].join (' ') 'border-sky-500 bg-sky-50 text-sky-700 dark:border-sky-400',
: [ 'dark:bg-sky-950 dark:text-sky-100']
'border-stone-300 bg-white text-stone-900 dark:border-stone-700', : [
'dark:bg-stone-900 dark:text-stone-100'].join (' ') }`}> 'border-stone-300 bg-white text-stone-900 dark:border-stone-700',
'dark:bg-stone-900 dark:text-stone-100'])}>
</button> </button>
<button <button
type="button" type="button"
onClick={() => updateQuery ({ view: 'list' })} onClick={() => updateQuery ({ view: 'list' })}
className={`rounded-full border px-4 py-2 text-sm ${ className={cn (
view === 'list' 'rounded-full border px-4 py-2 text-sm',
? [ view === 'list'
'border-sky-500 bg-sky-50 text-sky-700 dark:border-sky-400', ? [
'dark:bg-sky-950 dark:text-sky-100'].join (' ') 'border-sky-500 bg-sky-50 text-sky-700 dark:border-sky-400',
: [ 'dark:bg-sky-950 dark:text-sky-100']
'border-stone-300 bg-white text-stone-900 dark:border-stone-700', : [
'dark:bg-stone-900 dark:text-stone-100'].join (' ') }`}> 'border-stone-300 bg-white text-stone-900 dark:border-stone-700',
'dark:bg-stone-900 dark:text-stone-100'])}>
</button> </button>
</div> </div>
-48
ファイルの表示
@@ -1,48 +0,0 @@
import { render, screen } from '@testing-library/react'
import { MemoryRouter, Navigate, Route, Routes } from 'react-router-dom'
import { describe, expect, it } from 'vitest'
describe ('legacy post import routes', () => {
it ('redirects /posts/import to /posts/new', () => {
render (
<MemoryRouter initialEntries={['/posts/import']}>
<Routes>
<Route path="/posts/import" element={<Navigate to="/posts/new" replace/>}/>
<Route path="/posts/new" element={<div>SOURCE ROUTE</div>}/>
</Routes>
</MemoryRouter>)
expect (screen.getByText ('SOURCE ROUTE')).toBeInTheDocument ()
})
it ('redirects legacy review routes to /posts/new', () => {
render (
<MemoryRouter initialEntries={['/posts/import/legacy/review']}>
<Routes>
<Route
path="/posts/import/:sessionId/review"
element={<Navigate to="/posts/new" replace/>}/>
<Route path="/posts/new" element={<div>SOURCE ROUTE</div>}/>
</Routes>
</MemoryRouter>)
expect (screen.getByText ('SOURCE ROUTE')).toBeInTheDocument ()
})
it ('redirects legacy result routes to /posts', () => {
render (
<MemoryRouter initialEntries={['/posts/import/legacy/result']}>
<Routes>
<Route
path="/posts/import/:sessionId/result"
element={<Navigate to="/posts" replace/>}/>
<Route
path="/posts/new/:sessionId/result"
element={<Navigate to="/posts" replace/>}/>
<Route path="/posts" element={<div>POSTS ROUTE</div>}/>
</Routes>
</MemoryRouter>)
expect (screen.getByText ('POSTS ROUTE')).toBeInTheDocument ()
})
})
+134 -425
ファイルの表示
@@ -1,448 +1,157 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { fireEvent, screen, waitFor } from '@testing-library/react'
import { HelmetProvider } from 'react-helmet-async' import { useLocation } from 'react-router-dom'
import { MemoryRouter, Route, Routes } from 'react-router-dom'
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import { loadPostImportSession, savePostImportSession } from '@/lib/postImportSession'
import PostImportReviewPage from '@/pages/posts/PostImportReviewPage' import PostImportReviewPage from '@/pages/posts/PostImportReviewPage'
import { buildUser } from '@/test/factories' import { buildUser } from '@/test/factories'
import { buildPostImportRow } from '@/test/postImportFactories' import { renderWithProviders } from '@/test/render'
import type { DialogueFormAction, DialogueFormControls } from '@/lib/dialogues/useDialogue' import type { ReactNode } from 'react'
import type { PostImportRow } from '@/lib/postImportSession'
const api = vi.hoisted (() => ({ const api = vi.hoisted (() => ({
apiGet: vi.fn (), apiGet: vi.fn (),
apiPost: vi.fn (), apiPost: vi.fn (),
})) isApiError: vi.fn (() => false) }))
const toastApi = vi.hoisted (() => ({
toast: vi.fn (),
}))
const dialogue = vi.hoisted (() => ({
form: vi.fn (() => Promise.resolve ()),
}))
vi.mock ('@/lib/api', () => api) vi.mock ('@/lib/api', () => api)
vi.mock ('@/components/ui/use-toast', () => toastApi) vi.mock ('framer-motion', () => ({
vi.mock ('@/lib/dialogues/useDialogue', () => ({ AnimatePresence: ({ children }: { children?: ReactNode }) => <>{children}</>,
default: () => dialogue, motion: {
})) div: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
main: ({ children }: { children?: ReactNode }) => <main>{children}</main> } }))
const renderReviewPage = (route = '/posts/new/review') => const metadata = (url: string, title: string) => ({
render ( url,
<HelmetProvider> title,
<MemoryRouter initialEntries={[route]}> thumbnailBase: 'https://example.com/thumbnail.jpg',
<Routes> originalCreatedFrom: '',
<Route path="/posts/new" element={<div>SOURCE ROUTE</div>}/> originalCreatedBefore: '',
<Route duration: '',
path="/posts/new/review" tags: '',
element={<PostImportReviewPage user={buildUser ()}/>}/> parentPostIds: [],
<Route path="/posts" element={<div>POSTS ROUTE</div>}/> fieldWarnings: { },
</Routes> baseWarnings: [],
</MemoryRouter> displayTags: [] })
</HelmetProvider>)
const LocationProbe = () => {
const location = useLocation ()
return <output aria-label="current-location">{location.pathname}{location.search}</output>
}
const renderReviewPage = (urls: string[]) => {
const search = urls.map (url => encodeURIComponent (url)).join ('+')
return renderWithProviders (
<>
<PostImportReviewPage user={buildUser ()}/>
<LocationProbe/>
</>,
{ route: `/posts/new?urls=${ search }` })
}
describe ('PostImportReviewPage', () => { describe ('PostImportReviewPage', () => {
beforeEach (() => { beforeEach (() => {
sessionStorage.clear ()
vi.clearAllMocks () vi.clearAllMocks ()
globalThis.URL.createObjectURL = vi.fn (() => 'blob:preview') api.isApiError.mockReturnValue (false)
globalThis.URL.revokeObjectURL = vi.fn ()
api.apiGet.mockResolvedValue (new Blob (['img'], { type: 'image/png' }))
}) })
it ('redirects to /posts/new when no current work exists', async () => { it ('fetches metadata with at most four concurrent requests', async () => {
renderReviewPage () const resolvers: Array<(value: ReturnType<typeof metadata>) => void> = []
api.apiGet.mockImplementation ((_path, options) =>
new Promise (resolve => {
resolvers.push (resolve)
const url = String (options.params.url)
void url
}))
const urls = Array.from (
{ length: 6 },
(_, index) => `https://example.com/${ index + 1 }`)
renderReviewPage (urls)
await waitFor (() => expect (api.apiGet).toHaveBeenCalledTimes (4))
resolvers[0]?.(metadata (urls[0]!, 'first'))
expect (await screen.findAllByText ('first')).toHaveLength (2)
await waitFor (() => expect (api.apiGet).toHaveBeenCalledTimes (5))
expect (screen.getByRole ('button', { name: '追加' })).toBeDisabled ()
for (let index = 1; index < resolvers.length; ++index)
resolvers[index]?.(metadata (urls[index]!, `post ${ index + 1 }`))
})
it ('keeps successful rows when another metadata request fails', async () => {
api.apiGet
.mockResolvedValueOnce (metadata ('https://example.com/one', 'first'))
.mockRejectedValueOnce (new TypeError ('network'))
renderReviewPage (['https://example.com/one', 'https://example.com/two'])
expect (await screen.findAllByText ('first')).toHaveLength (2)
await waitFor (() => {
expect (screen.getAllByText ('登録不可')).toHaveLength (2)
})
expect (screen.getAllByRole ('button', { name: '編輯' })[0]).toBeEnabled ()
})
it ('shows existing posts with Active Storage thumbnail precedence', async () => {
api.apiGet.mockResolvedValue ({
...metadata ('https://example.com/post', ''),
existingPostId: 24,
existingPost: {
id: 24,
title: 'existing post',
url: 'https://example.com/post',
thumbnail: 'https://example.com/storage.jpg',
thumbnailBase: 'https://example.com/base.jpg' } })
renderReviewPage (['https://example.com/post'])
const disclosure = await screen.findByRole ('button', {
name: '既存投稿による自動スキップ 1件' })
expect (screen.getByRole ('button', { name: '追加' })).toBeDisabled ()
fireEvent.click (disclosure)
expect ((await screen.findAllByRole ('img', { name: 'サムネール' }))[0])
.toHaveAttribute ('src', 'https://example.com/storage.jpg')
})
it ('keeps a recoverable bulk failure on the review screen', async () => {
api.apiGet.mockResolvedValue (
metadata ('https://example.com/post', 'new post'))
api.apiPost.mockResolvedValue ({
results: [{
status: 'failed',
recoverable: true,
errors: { title: ['invalid title'] } }] })
renderReviewPage (['https://example.com/post'])
fireEvent.click (await screen.findByRole ('button', { name: '追加' }))
expect (await screen.findAllByText ('登録失敗')).toHaveLength (2)
expect (screen.getAllByText ('invalid title')).toHaveLength (2)
expect (screen.getAllByRole ('button', { name: '編輯' })[0]).toBeEnabled ()
expect (screen.getAllByRole ('button', { name: '再試行' })[0]).toBeEnabled ()
expect (screen.getByLabelText ('current-location')).toHaveTextContent ('/posts/new?')
})
it ('navigates to /posts after every submitted row is created', async () => {
api.apiGet.mockResolvedValue (
metadata ('https://example.com/post', 'new post'))
api.apiPost.mockResolvedValue ({
results: [{ status: 'created', post: { id: 42 } }] })
renderReviewPage (['https://example.com/post'])
fireEvent.click (await screen.findByRole ('button', { name: '追加' }))
await waitFor (() => { await waitFor (() => {
expect (screen.getByText ('SOURCE ROUTE')).toBeInTheDocument () expect (screen.getByLabelText ('current-location')).toHaveTextContent ('/posts')
}) })
}) expect (api.apiPost).toHaveBeenCalledWith ('/posts/bulk', expect.any (FormData))
it ('restores the current work on review reload', async () => {
savePostImportSession ({
source: 'https://example.com/post',
repairMode: 'all',
rows: [buildPostImportRow ({
sourceRow: 1,
attributes: { title: 'restored row' } })] })
renderReviewPage ()
expect (screen.getByRole ('heading', { name: '追加内容確認' })).toBeInTheDocument ()
expect (screen.queryByText ('広場に投稿を追加')).not.toBeInTheDocument ()
expect (screen.queryByText ('投稿インポート')).not.toBeInTheDocument ()
expect (await screen.findByText ('restored row')).toBeInTheDocument ()
})
it ('does not expose メタデータ to the user', async () => {
savePostImportSession ({
source: 'https://example.com/post',
repairMode: 'all',
rows: [buildPostImportRow ({
sourceRow: 1,
fieldWarnings: { title: ['自動取得に失敗しました.'] } })] })
renderReviewPage ()
expect (screen.queryByText (/メタデータ/)).not.toBeInTheDocument ()
})
it ('returns to /posts/new while keeping the current work', async () => {
savePostImportSession ({
source: 'https://example.com/post',
repairMode: 'all',
rows: [buildPostImportRow ({
sourceRow: 1,
attributes: { title: 'restored row' } })] })
renderReviewPage ()
fireEvent.click (await screen.findByRole ('button', { name: 'URL リスト入力へ戻る' }))
await waitFor (() => {
expect (screen.getByText ('SOURCE ROUTE')).toBeInTheDocument ()
})
expect (loadPostImportSession ()?.rows[0]?.attributes.title).toBe ('restored row')
})
it ('disables row editing and navigation while batch import is running', async () => {
let resolveValidation: ((value: { rows: PostImportRow[] }) => void) | null = null
savePostImportSession ({
source: 'https://example.com/post',
repairMode: 'all',
rows: [buildPostImportRow ({ sourceRow: 1 })] })
api.apiPost.mockImplementationOnce (() =>
new Promise<{ rows: PostImportRow[] }> (resolve => {
resolveValidation = resolve
}))
api.apiPost.mockResolvedValueOnce ({
created: 0,
skipped: 0,
failed: 0,
rows: [] })
renderReviewPage ()
fireEvent.click (screen.getByRole ('button', { name: '取込実行' }))
await waitFor (() => {
expect (screen.getByRole ('button', { name: '編輯' })).toBeDisabled ()
expect (screen.getByRole ('button', { name: 'URL リスト入力へ戻る' })).toBeDisabled ()
expect (screen.getByRole ('button', { name: '取込実行' })).toBeDisabled ()
})
resolveValidation?.({ rows: [buildPostImportRow ({ sourceRow: 1 })] })
})
it ('does not call import when validation omits a requested source row', async () => {
savePostImportSession ({
source: 'https://example.com/post-1\nhttps://example.com/post-2',
repairMode: 'all',
rows: [
buildPostImportRow ({ sourceRow: 1 }),
buildPostImportRow ({ sourceRow: 2 })] })
api.apiPost.mockResolvedValueOnce ({
rows: [buildPostImportRow ({ sourceRow: 1 })] })
renderReviewPage ()
fireEvent.click (screen.getByRole ('button', { name: '取込実行' }))
await waitFor (() => {
expect (toastApi.toast).toHaveBeenCalledWith (
expect.objectContaining ({ title: '再検証結果が不完全でした' }))
})
expect (api.apiPost).toHaveBeenCalledTimes (1)
})
it ('navigates to /posts when all rows finish as created or skipped', async () => {
savePostImportSession ({
source: 'https://example.com/post',
repairMode: 'all',
rows: [buildPostImportRow ({ sourceRow: 1 })] })
api.apiPost
.mockResolvedValueOnce ({
rows: [buildPostImportRow ({ sourceRow: 1 })] })
.mockResolvedValueOnce ({
created: 1,
skipped: 0,
failed: 0,
rows: [{
sourceRow: 1,
status: 'created',
post: { id: 1 } }] })
renderReviewPage ()
fireEvent.click (screen.getByRole ('button', { name: '取込実行' }))
await waitFor (() => {
expect (screen.getByText ('POSTS ROUTE')).toBeInTheDocument ()
})
expect (loadPostImportSession ()).toBeNull ()
})
it ('stays on review when a recoverable failed row remains', async () => {
savePostImportSession ({
source: 'https://example.com/post',
repairMode: 'all',
rows: [buildPostImportRow ({ sourceRow: 1 })] })
api.apiPost
.mockResolvedValueOnce ({
rows: [buildPostImportRow ({ sourceRow: 1 })] })
.mockResolvedValueOnce ({
created: 0,
skipped: 0,
failed: 1,
rows: [{
sourceRow: 1,
status: 'failed',
recoverable: true,
errors: { title: ['invalid'] } }] })
renderReviewPage ()
fireEvent.click (screen.getByRole ('button', { name: '取込実行' }))
expect (await screen.findByText ('invalid')).toBeInTheDocument ()
expect (screen.queryByText ('POSTS ROUTE')).not.toBeInTheDocument ()
})
it ('keeps edited recoverable rows in session before the next batch submit', async () => {
savePostImportSession ({
source: 'https://example.com/post',
repairMode: 'failed',
rows: [buildPostImportRow ({
sourceRow: 1,
attributes: { title: 'old title', duration: '2' },
importStatus: 'failed',
recoverable: true,
importErrors: { base: ['failed'] } })] })
api.apiPost
.mockResolvedValueOnce ({
rows: [buildPostImportRow ({
sourceRow: 1,
attributes: { title: 'edited title', duration: '2' },
importStatus: 'pending',
recoverable: true })] })
.mockResolvedValueOnce ({
rows: [buildPostImportRow ({
sourceRow: 1,
attributes: { title: 'edited title', duration: '2' },
importStatus: 'pending',
recoverable: true })] })
.mockResolvedValueOnce ({
created: 0,
skipped: 0,
failed: 1,
rows: [{
sourceRow: 1,
status: 'failed',
recoverable: true,
errors: { title: ['invalid'] } }] })
dialogue.form.mockImplementationOnce (async options => {
let actions: DialogueFormAction[] = []
const controls: DialogueFormControls = {
close: vi.fn (),
confirm: vi.fn (),
setActions: next => {
actions = next
} }
render (options.body (controls))
await waitFor (() => expect (actions.length).toBe (2))
fireEvent.change (screen.getByDisplayValue ('old title'), {
target: { value: 'edited title' } })
await actions.find (action => action.label === '編輯内容を保存')?.onSelect ()
})
renderReviewPage ()
fireEvent.click (screen.getByRole ('button', { name: '編輯' }))
await waitFor (() => {
const saved = loadPostImportSession ()
expect (saved?.rows[0]?.attributes.title).toBe ('edited title')
expect (saved?.rows[0]?.importStatus).toBe ('pending')
})
fireEvent.click (screen.getByRole ('button', { name: '取込実行' }))
await waitFor (() => {
expect (api.apiPost.mock.calls[1]?.[1]?.rows?.[0]?.attributes?.title)
.toBe ('edited title')
expect (api.apiPost.mock.calls[1]?.[1]?.rows?.[0]?.attributes?.duration)
.toBe ('2')
})
})
it ('retries only failed rows and does not resend created or skipped rows', async () => {
savePostImportSession ({
source: 'https://example.com/post',
repairMode: 'failed',
rows: [
buildPostImportRow ({
sourceRow: 1,
importStatus: 'created',
createdPostId: 1 }),
buildPostImportRow ({
sourceRow: 2,
importStatus: 'skipped',
skipReason: 'existing',
existingPostId: 2 }),
buildPostImportRow ({
sourceRow: 3,
importStatus: 'failed',
recoverable: true,
importErrors: { base: ['failed'] } })] })
api.apiPost
.mockResolvedValueOnce ({
rows: [buildPostImportRow ({
sourceRow: 3,
importStatus: 'pending',
recoverable: true })] })
.mockResolvedValueOnce ({
created: 0,
skipped: 1,
failed: 0,
rows: [{
sourceRow: 3,
status: 'skipped',
existingPostId: 9 }] })
renderReviewPage ()
fireEvent.click (screen.getByRole ('button', { name: '再試行' }))
await waitFor (() => {
expect (api.apiPost.mock.calls[1]?.[1]?.rows).toEqual ([
expect.objectContaining ({ sourceRow: 3 })])
})
})
it ('allows ready rows to be manually skipped and excludes them from API payloads', async () => {
savePostImportSession ({
source: 'https://example.com/post',
repairMode: 'all',
rows: [buildPostImportRow ({ sourceRow: 1 })] })
renderReviewPage ()
fireEvent.click (screen.getByRole ('checkbox', { name: 'スキップ' }))
await waitFor (() => {
expect (screen.getByText ('POSTS ROUTE')).toBeInTheDocument ()
})
expect (api.apiPost).not.toHaveBeenCalled ()
})
it ('restores values and errors when manual skip is cleared', async () => {
savePostImportSession ({
source: 'https://example.com/post',
repairMode: 'all',
rows: [buildPostImportRow ({
sourceRow: 1,
status: 'error',
attributes: { title: 'kept title' },
validationErrors: { title: ['invalid'] } })] })
renderReviewPage ()
const toggle = await screen.findByRole ('checkbox', { name: 'スキップ' })
fireEvent.click (toggle)
fireEvent.click (screen.getByRole ('checkbox', { name: 'スキップ' }))
expect (screen.getByText ('kept title')).toBeInTheDocument ()
expect (screen.getByText ('invalid')).toBeInTheDocument ()
})
it ('moves existing skipped rows into the collapsed section', async () => {
savePostImportSession ({
source: 'https://example.com/post',
repairMode: 'all',
rows: [
buildPostImportRow ({
sourceRow: 1,
attributes: { title: 'existing row' },
skipReason: 'existing',
existingPostId: 2 }),
buildPostImportRow ({
sourceRow: 2,
attributes: { title: 'normal row' } })] })
renderReviewPage ()
expect (screen.getByText ('既存投稿による自動スキップ 1件')).toBeInTheDocument ()
expect (screen.queryByText ('existing row')).not.toBeInTheDocument ()
expect (screen.getByText ('normal row')).toBeInTheDocument ()
fireEvent.click (screen.getByRole ('button', { name: '既存投稿による自動スキップ 1件' }))
expect (screen.getByText ('existing row')).toBeInTheDocument ()
expect (screen.queryAllByRole ('button', { name: '編輯' })).toHaveLength (1)
expect (screen.queryAllByRole ('checkbox', { name: 'スキップ' })).toHaveLength (1)
})
it ('navigates to /posts when the last failed row completes', async () => {
savePostImportSession ({
source: 'https://example.com/post',
repairMode: 'failed',
rows: [
buildPostImportRow ({
sourceRow: 1,
importStatus: 'created',
createdPostId: 1 }),
buildPostImportRow ({
sourceRow: 2,
importStatus: 'failed',
recoverable: true,
importErrors: { base: ['failed'] } })] })
api.apiPost
.mockResolvedValueOnce ({
rows: [buildPostImportRow ({
sourceRow: 2,
importStatus: 'pending',
recoverable: true })] })
.mockResolvedValueOnce ({
created: 1,
skipped: 0,
failed: 0,
rows: [{
sourceRow: 2,
status: 'created',
post: { id: 2 } }] })
renderReviewPage ()
fireEvent.click (screen.getByRole ('button', { name: '再試行' }))
await waitFor (() => {
expect (screen.getByText ('POSTS ROUTE')).toBeInTheDocument ()
})
})
it ('sorts recoverable pending validation rows to the top in repair mode', () => {
savePostImportSession ({
source: '',
repairMode: 'failed',
rows: [
buildPostImportRow ({
sourceRow: 2,
attributes: { title: 'normal row' } }),
buildPostImportRow ({
sourceRow: 1,
attributes: { title: 'repair row' },
importStatus: 'pending',
recoverable: true,
validationErrors: { title: ['invalid'] } })] })
const { container } = renderReviewPage ()
const titles = Array.from (container.querySelectorAll ('.line-clamp-2')).map (
node => node.textContent)
expect (titles[0]).toBe ('repair row')
}) })
}) })
+6 -13
ファイルの表示
@@ -108,7 +108,6 @@ const emptyAttributes = () => ({
originalCreatedFrom: '', originalCreatedFrom: '',
originalCreatedBefore: '', originalCreatedBefore: '',
duration: '', duration: '',
videoMs: '',
tags: '', tags: '',
parentPostIds: '' }) parentPostIds: '' })
@@ -120,7 +119,6 @@ const emptyProvenance = () => ({
originalCreatedFrom: 'automatic', originalCreatedFrom: 'automatic',
originalCreatedBefore: 'automatic', originalCreatedBefore: 'automatic',
duration: 'automatic', duration: 'automatic',
videoMs: 'automatic',
tags: 'automatic', tags: 'automatic',
parentPostIds: 'automatic' }) as const parentPostIds: 'automatic' }) as const
@@ -171,16 +169,17 @@ const buildInitialRows = (urls: string[]): PostImportRow[] => {
urls.map ((url, index) => { urls.map ((url, index) => {
const sourceRow = index + 1 const sourceRow = index + 1
const rowIssues = issuesByRow[sourceRow] ?? [] const rowIssues = issuesByRow[sourceRow] ?? []
const validationErrors: Record<string, string[]> =
rowIssues.length > 0
? { url: [...rowIssues] }
: { }
return { return {
sourceRow, sourceRow,
url, url,
attributes: emptyAttributes (), attributes: emptyAttributes (),
fieldWarnings: { }, fieldWarnings: { },
baseWarnings: [], baseWarnings: [],
validationErrors: validationErrors,
rowIssues.length > 0
? { url: [...rowIssues] }
: { },
provenance: emptyProvenance (), provenance: emptyProvenance (),
tagSources: emptyTagSources (), tagSources: emptyTagSources (),
status: rowIssues.length > 0 ? 'error' : 'pending', status: rowIssues.length > 0 ? 'error' : 'pending',
@@ -294,7 +293,6 @@ const buildDryRunFormData = (row: PostImportRow): FormData => {
formData.append ( formData.append (
'original_created_before', 'original_created_before',
String (row.attributes.originalCreatedBefore ?? '')) String (row.attributes.originalCreatedBefore ?? ''))
formData.append ('video_ms', String (row.attributes.videoMs ?? ''))
formData.append ('duration', String (row.attributes.duration ?? '')) formData.append ('duration', String (row.attributes.duration ?? ''))
if (!(hasThumbnailBaseValue (row.attributes.thumbnailBase)) && row.thumbnailFile != null) if (!(hasThumbnailBaseValue (row.attributes.thumbnailBase)) && row.thumbnailFile != null)
formData.append ('thumbnail', row.thumbnailFile) formData.append ('thumbnail', row.thumbnailFile)
@@ -321,7 +319,6 @@ const mergeDryRunRow = (
originalCreatedFrom: result.originalCreatedFrom ?? '', originalCreatedFrom: result.originalCreatedFrom ?? '',
originalCreatedBefore: result.originalCreatedBefore ?? '', originalCreatedBefore: result.originalCreatedBefore ?? '',
duration: result.duration ?? '', duration: result.duration ?? '',
videoMs: result.videoMs ?? '',
tags: result.tags ?? '', tags: result.tags ?? '',
parentPostIds: String ( parentPostIds: String (
result.parentPostIds ?? currentRow.attributes.parentPostIds ?? '') }, result.parentPostIds ?? currentRow.attributes.parentPostIds ?? '') },
@@ -355,7 +352,6 @@ const buildPreviewResetSnapshot = (
originalCreatedFrom: preview.originalCreatedFrom ?? '', originalCreatedFrom: preview.originalCreatedFrom ?? '',
originalCreatedBefore: preview.originalCreatedBefore ?? '', originalCreatedBefore: preview.originalCreatedBefore ?? '',
duration: preview.duration ?? '', duration: preview.duration ?? '',
videoMs: preview.videoMs ?? '',
tags: preview.tags ?? '', tags: preview.tags ?? '',
parentPostIds: String (preview.parentPostIds ?? '') }, parentPostIds: String (preview.parentPostIds ?? '') },
displayTags: cloneDisplayTags (preview.displayTags), displayTags: cloneDisplayTags (preview.displayTags),
@@ -486,11 +482,9 @@ const mergePreviewRow = (
nextRow.attributes.tags = preview.tags ?? '' nextRow.attributes.tags = preview.tags ?? ''
nextRow.displayTags = cloneDisplayTags (preview.displayTags) nextRow.displayTags = cloneDisplayTags (preview.displayTags)
} }
nextRow.tagSources.automatic = preview.tags ?? '' nextRow.tagSources!.automatic = preview.tags ?? ''
if (currentRow.provenance.parentPostIds !== 'manual') if (currentRow.provenance.parentPostIds !== 'manual')
nextRow.attributes.parentPostIds = String (preview.parentPostIds ?? '') nextRow.attributes.parentPostIds = String (preview.parentPostIds ?? '')
if (currentRow.provenance.videoMs !== 'manual')
nextRow.attributes.videoMs = preview.videoMs ?? ''
if (currentRow.provenance.duration !== 'manual') if (currentRow.provenance.duration !== 'manual')
nextRow.attributes.duration = preview.duration ?? '' nextRow.attributes.duration = preview.duration ?? ''
@@ -526,7 +520,6 @@ const buildBulkFormData = (rows: PostImportRow[]): FormData => {
parent_post_ids: row.attributes.parentPostIds ?? '', parent_post_ids: row.attributes.parentPostIds ?? '',
original_created_from: row.attributes.originalCreatedFrom ?? '', original_created_from: row.attributes.originalCreatedFrom ?? '',
original_created_before: row.attributes.originalCreatedBefore ?? '', original_created_before: row.attributes.originalCreatedBefore ?? '',
video_ms: row.attributes.videoMs ?? '',
duration: row.attributes.duration ?? '' })))) duration: row.attributes.duration ?? '' }))))
rows.forEach ((row, index) => { rows.forEach ((row, index) => {
if (!(hasThumbnailBaseValue (row.attributes.thumbnailBase)) && row.thumbnailFile != null) if (!(hasThumbnailBaseValue (row.attributes.thumbnailBase)) && row.thumbnailFile != null)
+22 -49
ファイルの表示
@@ -1,21 +1,12 @@
import { fireEvent, screen, waitFor } from '@testing-library/react' import { fireEvent, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import PostImportSourcePage from '@/pages/posts/PostImportSourcePage' import PostImportSourcePage from '@/pages/posts/PostImportSourcePage'
import { buildUser } from '@/test/factories' import { buildUser } from '@/test/factories'
import { buildPostImportRow } from '@/test/postImportFactories'
import { renderWithProviders } from '@/test/render' import { renderWithProviders } from '@/test/render'
const api = vi.hoisted (() => ({
apiPost: vi.fn (),
isApiError: vi.fn () }))
const router = vi.hoisted (() => ({ navigate: vi.fn () })) const router = vi.hoisted (() => ({ navigate: vi.fn () }))
const toastApi = vi.hoisted (() => ({ toast: vi.fn () }))
vi.mock ('@/lib/api', () => api)
vi.mock ('@/components/ui/use-toast', () => toastApi)
vi.mock ('react-router-dom', async importOriginal => ({ vi.mock ('react-router-dom', async importOriginal => ({
...await importOriginal<typeof import('react-router-dom')> (), ...await importOriginal<typeof import('react-router-dom')> (),
useNavigate: () => router.navigate })) useNavigate: () => router.navigate }))
@@ -24,71 +15,53 @@ describe ('PostImportSourcePage', () => {
beforeEach (() => { beforeEach (() => {
sessionStorage.clear () sessionStorage.clear ()
vi.clearAllMocks () vi.clearAllMocks ()
api.isApiError.mockReturnValue (false)
}) })
it ('shows no empty error initially and validates only after Next is pressed', () => { it ('validates an empty source only after Next is pressed', () => {
renderWithProviders (<PostImportSourcePage user={buildUser ()}/>) renderWithProviders (<PostImportSourcePage user={buildUser ()}/>)
expect (screen.getByRole ('heading', { name: '広場に投稿を追加' })).toBeInTheDocument ()
expect (screen.queryByText ('投稿インポート')).not.toBeInTheDocument ()
expect (screen.queryByText ('URL を入力してください.')).not.toBeInTheDocument () expect (screen.queryByText ('URL を入力してください.')).not.toBeInTheDocument ()
fireEvent.click (screen.getByRole ('button', { name: '次へ' })) fireEvent.click (screen.getByRole ('button', { name: '次へ' }))
expect (screen.getByText ('URL を入力してください.')).toBeInTheDocument () expect (screen.getByText ('URL を入力してください.')).toBeInTheDocument ()
expect (api.apiPost).not.toHaveBeenCalled () expect (router.navigate).not.toHaveBeenCalled ()
}) })
it ('shows frontend URL issues with line numbers without requesting preview', () => { it ('reports frontend URL issues with original line numbers', () => {
renderWithProviders (<PostImportSourcePage user={buildUser ()}/>) renderWithProviders (<PostImportSourcePage user={buildUser ()}/>)
const input = screen.getByRole ('textbox', { name: '' }) const input = screen.getByRole ('textbox', { name: '' })
fireEvent.change (input, { fireEvent.change (input, {
target: { value: '\nftp://example.com/file\nhttps://example.com/valid' } }) target: { value: '\nftp://example.com/file\nhttps://example.com/valid' } })
expect (screen.queryByText (/2 行目/)).not.toBeInTheDocument ()
fireEvent.click (screen.getByRole ('button', { name: '次へ' })) fireEvent.click (screen.getByRole ('button', { name: '次へ' }))
expect (screen.getByText (/2 行目: HTTP または HTTPS/)).toBeInTheDocument () expect (screen.getByText (/2 行目: HTTP または HTTPS/)).toBeInTheDocument ()
expect (screen.getByText ('ftp://example.com/file')).toBeInTheDocument () expect (screen.getByText ('ftp://example.com/file')).toBeInTheDocument ()
expect (input).toHaveAttribute ('aria-invalid', 'true') expect (input).toHaveAttribute ('aria-invalid', 'true')
expect (input.getAttribute ('aria-describedby')).toContain ('post-import-source-issues')
expect (api.apiPost).not.toHaveBeenCalled ()
})
it ('shows backend URL errors against original input without a session', async () => {
api.apiPost.mockResolvedValue ({
rows: [buildPostImportRow ({
sourceRow: 2,
url: 'https://example.com/canonical',
status: 'error',
validationErrors: { url: ['URL が重複しています.'] } })] })
renderWithProviders (<PostImportSourcePage user={buildUser ()}/>)
fireEvent.change (screen.getByRole ('textbox', { name: '' }), {
target: { value: '\nhttps://example.com/original' } })
fireEvent.click (screen.getByRole ('button', { name: '次へ' }))
expect (await screen.findByText ('2 行目: URL が重複しています.')).toBeInTheDocument ()
expect (screen.getAllByText ('https://example.com/original')).toHaveLength (2)
expect (router.navigate).not.toHaveBeenCalled () expect (router.navigate).not.toHaveBeenCalled ()
expect (Array.from ({ length: sessionStorage.length }, (_, index) =>
sessionStorage.key (index))).not.toContainEqual(
expect.stringMatching (/^post-import-session:/))
}) })
it ('navigates to /posts/new with query state after a successful preview', async () => { it ('navigates with individually encoded URLs without calling an API', () => {
api.apiPost.mockResolvedValue ({ rows: [buildPostImportRow ()] })
renderWithProviders (<PostImportSourcePage user={buildUser ()}/>) renderWithProviders (<PostImportSourcePage user={buildUser ()}/>)
fireEvent.change (screen.getByRole ('textbox', { name: '' }), { fireEvent.change (screen.getByRole ('textbox', { name: '' }), {
target: { value: 'https://example.com/post' } }) target: {
value: 'https://example.com/one+a\nhttps://example.com/two?value=b+c' } })
fireEvent.click (screen.getByRole ('button', { name: '次へ' })) fireEvent.click (screen.getByRole ('button', { name: '次へ' }))
await waitFor (() => { expect (router.navigate).toHaveBeenCalledWith (
expect (router.navigate).toHaveBeenCalledWith ( '/posts/new?urls=https%3A%2F%2Fexample.com%2Fone%2Ba'
expect.stringMatching (/^\/posts\/new\?/)) + '+https%3A%2F%2Fexample.com%2Ftwo%3Fvalue%3Db%2Bc')
}) })
expect (Array.from ({ length: sessionStorage.length }, (_, index) =>
sessionStorage.key (index))).not.toContainEqual( it ('disables Next when the encoded request target reaches 4096 bytes', () => {
expect.stringMatching (/^post-import-session:/)) renderWithProviders (<PostImportSourcePage user={buildUser ()}/>)
const input = screen.getByRole ('textbox', { name: '' })
fireEvent.change (input, {
target: { value: `https://example.com/${ 'a'.repeat (6_200) }` } })
expect (screen.getByRole ('button', { name: '次へ' })).toBeDisabled ()
}) })
}) })
+9 -13
ファイルの表示
@@ -1,5 +1,4 @@
import { screen } from '@testing-library/react' import { screen } from '@testing-library/react'
import { MemoryRouter, Route, Routes } from 'react-router-dom'
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import PostNewPage from '@/pages/posts/PostNewPage' import PostNewPage from '@/pages/posts/PostNewPage'
@@ -7,6 +6,7 @@ import { buildUser } from '@/test/factories'
import { renderWithProviders } from '@/test/render' import { renderWithProviders } from '@/test/render'
const api = vi.hoisted (() => ({ const api = vi.hoisted (() => ({
apiGet: vi.fn (),
apiPost: vi.fn (), apiPost: vi.fn (),
isApiError: vi.fn () })) isApiError: vi.fn () }))
@@ -16,27 +16,23 @@ describe ('PostNewPage', () => {
beforeEach (() => { beforeEach (() => {
vi.clearAllMocks () vi.clearAllMocks ()
api.isApiError.mockReturnValue (false) api.isApiError.mockReturnValue (false)
api.apiGet.mockResolvedValue ({
url: 'https://example.com/post',
title: 'post',
tags: '' })
sessionStorage.clear () sessionStorage.clear ()
}) })
it ('shows the source page on /posts/new', () => { it ('shows the source page on /posts/new', () => {
renderWithProviders ( renderWithProviders (<PostNewPage user={buildUser ()}/>, {
<MemoryRouter initialEntries={['/posts/new']}> route: '/posts/new' })
<Routes>
<Route path="/posts/new" element={<PostNewPage user={buildUser ()}/>}/>
</Routes>
</MemoryRouter>)
expect (screen.getByRole ('heading', { name: '広場に投稿を追加' })).toBeInTheDocument () expect (screen.getByRole ('heading', { name: '広場に投稿を追加' })).toBeInTheDocument ()
}) })
it ('shows the review page when query state is present', () => { it ('shows the review page when query state is present', () => {
renderWithProviders ( renderWithProviders (<PostNewPage user={buildUser ()}/>, {
<MemoryRouter initialEntries={['/posts/new?url=https%3A%2F%2Fexample.com%2Fpost']}> route: '/posts/new?urls=https%3A%2F%2Fexample.com%2Fpost' })
<Routes>
<Route path="/posts/new" element={<PostNewPage user={buildUser ()}/>}/>
</Routes>
</MemoryRouter>)
expect (screen.getByRole ('heading', { name: '追加内容確認' })).toBeInTheDocument () expect (screen.getByRole ('heading', { name: '追加内容確認' })).toBeInTheDocument ()
expect (screen.queryByText ('広場に投稿を追加')).not.toBeInTheDocument () expect (screen.queryByText ('広場に投稿を追加')).not.toBeInTheDocument ()
+4 -5
ファイルの表示
@@ -6,7 +6,6 @@ import { dateString } from '@/lib/utils'
import { buildTag, buildUser } from '@/test/factories' import { buildTag, buildUser } from '@/test/factories'
import { renderWithProviders } from '@/test/render' import { renderWithProviders } from '@/test/render'
import type { ReactNode } from 'react'
import type { NicoTag } from '@/types' import type { NicoTag } from '@/types'
const api = vi.hoisted (() => ({ const api = vi.hoisted (() => ({
@@ -27,10 +26,10 @@ const scrollIntoView = vi.fn ()
vi.mock ('@/lib/api', () => api) vi.mock ('@/lib/api', () => api)
vi.mock ('@/components/ui/use-toast', () => toastApi) vi.mock ('@/components/ui/use-toast', () => toastApi)
vi.mock ('@/components/dialogues/DialogueProvider', () => ({ vi.mock ('@/components/dialogues/DialogueProvider', async importOriginal => ({
default: ({ children }: { children: ReactNode }) => <>{children}</>, ...await importOriginal<
useDialogue: () => dialogue, typeof import('@/components/dialogues/DialogueProvider')> (),
})) useDialogue: () => dialogue }))
const buildNicoTag = (values: Partial<NicoTag> = {}): NicoTag => ({ const buildNicoTag = (values: Partial<NicoTag> = {}): NicoTag => ({
...buildTag (), ...buildTag (),
+8 -6
ファイルの表示
@@ -40,10 +40,10 @@ const postEmbed = vi.hoisted (() => ({
vi.mock ('@/lib/api', () => api) vi.mock ('@/lib/api', () => api)
vi.mock ('@/lib/posts', () => postsApi) vi.mock ('@/lib/posts', () => postsApi)
vi.mock ('@/components/dialogues/DialogueProvider', () => ({ vi.mock ('@/components/dialogues/DialogueProvider', async importOriginal => ({
default: ({ children }: { children: ReactNode }) => <>{children}</>, ...await importOriginal<
useDialogue: () => dialogue, typeof import('@/components/dialogues/DialogueProvider')> (),
})) useDialogue: () => dialogue }))
vi.mock ('@/components/PostEmbed', () => ({ vi.mock ('@/components/PostEmbed', () => ({
default: (props: { default: (props: {
ref?: { current: unknown } ref?: { current: unknown }
@@ -262,7 +262,9 @@ describe ('TheatreDetailPage', () => {
expect (postEmbed.seek).not.toHaveBeenCalledWith (0) expect (postEmbed.seek).not.toHaveBeenCalledWith (0)
}) })
it ('shows child tags from the post tag tree in both vertical and horizontal layouts', async () => { it (
'shows child tags from the post tag tree in both vertical and horizontal layouts',
async () => {
const childTag = buildTag ({ id: 12, name: '子タグ', category: 'general' }) const childTag = buildTag ({ id: 12, name: '子タグ', category: 'general' })
const parentTag = buildTag ({ const parentTag = buildTag ({
id: 11, id: 11,
@@ -307,7 +309,7 @@ describe ('TheatreDetailPage', () => {
expect (within (tagSection ()).getAllByRole ('link', { name: '子タグ' })) expect (within (tagSection ()).getAllByRole ('link', { name: '子タグ' }))
.toHaveLength (1) .toHaveLength (1)
}) })
}) })
it ('does not advance host post while video length is unknown', async () => { it ('does not advance host post while video length is unknown', async () => {
api.apiPut.mockImplementation ((path: string) => { api.apiPut.mockImplementation ((path: string) => {
+3 -7
ファイルの表示
@@ -1197,13 +1197,9 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
}, [hasUnsavedThemeChanges, savedThemeSlots]) }, [hasUnsavedThemeChanges, savedThemeSlots])
useEffect (() => { useEffect (() => {
registerUnsavedChangesSource ({ return registerUnsavedChangesSource ({
dirty: hasPageUnsavedChanges, dirty: hasPageUnsavedChanges,
discard: discardAllDirtyChanges }) discard: discardAllDirtyChanges })
return () => {
registerUnsavedChangesSource (null)
}
}, [discardAllDirtyChanges, hasPageUnsavedChanges, registerUnsavedChangesSource]) }, [discardAllDirtyChanges, hasPageUnsavedChanges, registerUnsavedChangesSource])
useKeyboardShortcuts ({ useKeyboardShortcuts ({
-10
ファイルの表示
@@ -1,10 +0,0 @@
import { describe, it } from 'vitest'
describe ('pending high-level browser coverage', () => {
it.todo ('adds MSW-backed API boundary tests in a follow-up issue')
it.todo ('covers TheatreDetailPage with timer polling, comment posting, and next-post updates')
it.todo ('covers NicoTagListPage linking and pagination against realistic API payloads')
it.todo ('covers TagDetailSidebar drag/drop parent-child editing with pointer-event fidelity')
it.todo ('covers TopNav desktop and mobile menu flows as browser-level integration tests')
it.todo ('covers full App bootstrap for user creation, user verification, and 503 handling')
})
+1 -1
ファイルの表示
@@ -1,4 +1,4 @@
import type { PostImportRow } from '@/lib/postImportSession' import type { PostImportRow } from '@/lib/postImportTypes'
export const buildPostImportRow = ( export const buildPostImportRow = (
overrides: Partial<PostImportRow> = {}, overrides: Partial<PostImportRow> = {},
+33 -15
ファイルの表示
@@ -1,7 +1,8 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { render } from '@testing-library/react' import { render } from '@testing-library/react'
import { createContext, useContext, useState } from 'react'
import { HelmetProvider } from 'react-helmet-async' import { HelmetProvider } from 'react-helmet-async'
import { MemoryRouter } from 'react-router-dom' import { createMemoryRouter, RouterProvider } from 'react-router-dom'
import DialogueProvider from '@/components/dialogues/DialogueProvider' import DialogueProvider from '@/components/dialogues/DialogueProvider'
import { KeyboardShortcutsProvider } from '@/lib/useKeyboardShortcuts' import { KeyboardShortcutsProvider } from '@/lib/useKeyboardShortcuts'
@@ -13,6 +14,20 @@ type Options = {
route?: string route?: string
} }
const TestContentContext = createContext<ReactNode> (null)
const TestRoute = () => {
const children = useContext (TestContentContext)
return (
<UnsavedChangesGuardProvider>
<KeyboardShortcutsProvider>
{children}
</KeyboardShortcutsProvider>
</UnsavedChangesGuardProvider>)
}
export const renderWithProviders = ( export const renderWithProviders = (
ui: ReactElement, ui: ReactElement,
options: Options = {}, options: Options = {},
@@ -22,20 +37,23 @@ export const renderWithProviders = (
queries: { retry: false } }, queries: { retry: false } },
}) })
const Wrapper = ({ children }: { children: ReactNode }) => ( const Wrapper = ({ children }: { children: ReactNode }) => {
<QueryClientProvider client={queryClient}> const [router] = useState (() => createMemoryRouter ([{
<HelmetProvider> path: '*',
<MemoryRouter initialEntries={[options.route ?? '/']}> element: <TestRoute/> }], {
<DialogueProvider> initialEntries: [options.route ?? '/'] }))
<UnsavedChangesGuardProvider>
<KeyboardShortcutsProvider> return (
{children} <QueryClientProvider client={queryClient}>
</KeyboardShortcutsProvider> <HelmetProvider>
</UnsavedChangesGuardProvider> <DialogueProvider>
</DialogueProvider> <TestContentContext.Provider value={children}>
</MemoryRouter> <RouterProvider router={router}/>
</HelmetProvider> </TestContentContext.Provider>
</QueryClientProvider>) </DialogueProvider>
</HelmetProvider>
</QueryClientProvider>)
}
return { return {
queryClient, queryClient,