コミットを比較

..

6 コミット

作成者 SHA1 メッセージ 日付
みてるぞ 90c1842224 #317 2026-04-26 21:24:50 +09:00
みてるぞ d2eb69d3b0 #317 2026-04-26 20:53:20 +09:00
みてるぞ 5f0c1953ce #317 2026-04-26 20:17:05 +09:00
みてるぞ 6ac044278f #317 2026-04-26 18:52:31 +09:00
みてるぞ c4f5df8b44 #317 2026-04-26 17:33:26 +09:00
みてるぞ e3780e2982 #317 2026-04-26 16:08:32 +09:00
223個のファイルの変更1596行の追加13845行の削除
-35
ファイルの表示
@@ -1,35 +0,0 @@
## 背景
なぜ必要か。
## 対象範囲
- backend:
- frontend:
- docs:
- migration:
## やること
- [ ]
## 受け入れ条件
- [ ]
## 実行すべき確認
- [ ] `cd backend && bundle exec rspec`
- [ ] `cd frontend && npm run build`
- [ ] `cd frontend && npm run lint`
## 禁止事項
- unrelated refactor はしない
- 既存 API response shape を壊さない
- 認証・認可・BAN を弱めない
## Codex への指示
この issue を読んで実装してください。
不明点があれば、実装前に調査結果と選択肢を提示してください。
-228
ファイルの表示
@@ -1,228 +0,0 @@
# AGENTS.md
## Project overview
BTRC Hub / タグ広場 is a split Rails API and React frontend repository.
- Backend: Rails API under `backend/`.
- Frontend: React + TypeScript + Vite under `frontend/`.
- Docs: lightweight command notes under `docs/`.
- There is no README or Makefile at the repository root as of this inspection.
## Stack
- Backend: Ruby `3.2.2` from `backend/.ruby-version`, Rails `~> 8.0.2`.
- Backend dependencies include `mysql2`, `sqlite3`, `rspec-rails`,
`factory_bot_rails`, `rack-cors`, `jwt`, `discard`, `gollum`, `whenever`,
`aws-sdk-s3`, `brakeman`, and `rubocop-rails-omakase`.
- Frontend: React `^19.1.0`, TypeScript `~5.8.3`, Vite `^6.3.5`.
- Frontend data/UI dependencies include Axios, TanStack Query, Tailwind CSS,
Framer Motion, Radix UI components, lucide-react, MDX/Markdown tooling, and
Zustand.
## Main directories
- `backend/app/controllers`: Rails API controllers.
- `backend/app/models`: Active Record models.
- `backend/app/representations`: API response representation classes.
- `backend/app/services`: domain services such as version recording,
wiki commit, YouTube sync, and similarity calculation.
- `backend/config/routes.rb`: API routes.
- `backend/db/migrate`: migrations.
- `backend/db/schema.rb`: current schema snapshot.
- `backend/lib/tasks`: custom Rake tasks.
- `backend/spec`: RSpec tests.
- `backend/test`: Rails minitest files that still exist in the tree.
- `frontend/src/App.tsx`: frontend route definitions and initial user setup.
- `frontend/src/pages`: page-level React components.
- `frontend/src/components`: shared and feature components.
- `frontend/src/lib`: API client helpers, query keys, prefetchers, and domain helpers.
- `frontend/src/stores`: Zustand stores.
- `docs/commands.md`: command notes.
## Commands
Only list commands that are backed by files inspected in this repository.
### Backend
The following binstubs exist under `backend/bin`:
```sh
cd backend
bin/setup
bin/dev
bin/rails
bin/rake
bin/rubocop
bin/brakeman
bin/kamal
bin/thrust
```
Common Rails/Rake usage through existing binstubs:
```sh
cd backend
bin/rails db:prepare
bin/rails db:migrate
bin/rails routes
bin/rails server
bin/rake
bin/rubocop
bin/brakeman
```
RSpec is present in `Gemfile` and `.rspec` exists:
```sh
cd backend
bundle exec rspec
```
### Frontend
The following npm scripts exist in `frontend/package.json`:
```sh
cd frontend
npm run dev
npm run build
npm run lint
npm run test
npm run test:run
npm run preview
```
`npm run build` runs `tsc -b && vite build`, then `postbuild` runs
`node scripts/generate-sitemap.js`.
`npm run test` runs Vitest in watch mode. Use `npm run test:run` for a non-watch frontend test run.
## Coding style
- Prefer precise, minimal changes.
- Do not flatter or over-explain.
- Explain risks directly.
- Prefer single quotes for strings unless interpolation or escaping makes
double quotes better.
- Ruby: never put a space before method-call parentheses.
- Ruby: never put a line break immediately before `)`.
- Ruby: do not use `%w` or `%i`.
- Ruby hashes are not blocks; keep `}` on the same line as the final pair.
- Ruby hashes keep the first pair on the same line as `{` unless line length
requires a break.
- Ruby blocks use separate `{ ... }` rules from hashes, with 2-space body
indentation.
- For arrays, never put whitespace or a line break immediately before `]`.
- Keep the first element on the same line as `[` by default.
- If an array would exceed the line limit, break after `[` and indent
elements by 4 spaces.
- TypeScript and Python: use GNU-style spacing before parentheses where
syntactically valid.
- Never write Ruby, TypeScript, or TSX lines longer than 99 characters.
- Aim to keep Ruby, TypeScript, and TSX lines within 79 characters where practical.
- TypeScript and TSX use 4-space logical indentation.
- In TypeScript and TSX only, replace every leading run of 8 spaces with a tab.
- Tabs are only for leading indentation, never for spaces after non-space text.
- Do not add production dependencies without explicit approval.
## Backend rules
- Inspect existing routes, controllers, models, services, and specs before
editing backend behavior.
- For API behavior changes, add or update request specs under `backend/spec/requests`.
- Prefer RSpec for new backend tests; existing minitest files under
`backend/test` do not make minitest the default for new coverage.
- Do not weaken authentication, BAN user checks, or IP BAN checks.
- Preserve the `X-Transfer-Code` user identification flow unless the task
explicitly changes authentication.
- Be careful with version tables, `version_no`, optimistic concurrency,
wiki revisions, and restore/diff behavior.
- Be careful with tag names, tag normalization, implications, similarities, and discard behavior.
- Be sensitive to N+1 queries; avoid introducing them and proactively fix
existing N+1 issues in the code path being edited.
- Keep migration files and `backend/db/schema.rb` consistent when changing schema.
## Frontend rules
- Use `frontend/src/lib/api.ts` for API calls so headers and camelCase conversion stay consistent.
- Add or reuse TanStack Query keys through `frontend/src/lib/queryKeys.ts`;
avoid ad hoc query key arrays.
- Encode URL path-segment values with `encodeURIComponent`.
- React hooks must be called unconditionally.
- Keep page-level code under `frontend/src/pages` and shared UI/feature code
under `frontend/src/components` unless existing patterns point elsewhere.
- Match existing Tailwind, component, and import alias conventions.
### Frontend TSX style
- Preserve the local TSX formatting style.
- Do not normalize TSX to common Prettier-style React formatting unless
explicitly asked.
- Prefer `const` arrow functions for TypeScript/TSX component and helper declarations.
- Put two blank lines before and after top-level `const` function
declarations, unless imports, exports, or file boundaries make that awkward.
- In TSX, indent with 4-space logical indentation.
- A leading tab is exactly equivalent to 8 leading spaces.
- Keep a tag's closing marker on the same line as the final prop when the tag
spans multiple lines.
- Do not put `/>` or `>` on its own line unless the existing surrounding code
does so.
- Keep JSX closing parentheses in the existing compact style, for example
`</div>)` rather than moving `)` onto a separate line.
- Do not add braces around `if`, `else`, or `for` bodies when the body is a
single physical line.
- Always add braces around `if`, `else`, or `for` bodies when the body spans
two or more physical lines, even if it is one statement.
Preferred:
```tsx
const PostFormTagsArea: FC<Props> = ({ tags, setTags, errors, ...rest }) => {
return (
<TextArea
{...rest}
ref={ref}
value={tags}
invalid={errors && errors.length > 0}
onChange={ev => setTags (ev.target.value)}/>)
}
```
Avoid:
```tsx
function PostFormTagsArea ({ tags, setTags }: Props) {
return (
<TextArea
value={tags}
onChange={ev => setTags (ev.target.value)}
/>
)
}
```
## Codex workflow
- First inspect existing patterns; do not invent new architecture when a local convention exists.
- Keep changes scoped to the requested issue.
- Do not scan or summarize dependency/generated/runtime directories such as
`node_modules`, `dist`, `tmp`, `log`, and `storage` unless explicitly needed.
- Before touching wiki, tag, versioning, BAN, IP BAN, or authentication
behavior, inspect the related request specs and service objects.
- If frontend code changes, run the existing frontend verification commands
that apply: `npm run build`, `npm run lint`, and `npm run test:run`.
- If backend code changes, run the relevant RSpec command; for broad backend
changes, run `bundle exec rspec`.
- If a verification command cannot be run or fails, report the exact command and failure.
## Completion criteria
A task is complete only when:
- implementation is complete,
- relevant verification commands pass, or failures are clearly explained,
- unrelated files are not changed,
- migrations and schema are consistent when schema changes are made,
- user-facing behavior is documented when needed.
-212
ファイルの表示
@@ -1,212 +0,0 @@
# backend/AGENTS.md
## Scope
These rules apply to work under `backend/`.
This is a Rails API app using Active Record, RSpec, request specs,
service objects, representation classes, and version tables for post/tag/wiki
history.
## Commands
Use commands backed by files and dependencies in this directory:
```sh
bin/setup
bin/dev
bin/rails
bin/rake
bin/rubocop
bin/brakeman
bundle exec rspec
```
Common checks:
```sh
bundle exec rspec
bin/rubocop
bin/brakeman
```
Common Rails commands:
```sh
bin/rails db:prepare
bin/rails db:migrate
bin/rails routes
bin/rails server
```
After backend behavior changes, run the relevant RSpec files. For broad backend changes, run:
```sh
bundle exec rspec
```
If a command cannot be run or fails, report the exact command and failure.
## Rails structure
- `app/controllers`: API controllers.
- `app/models`: Active Record models and concerns.
- `app/representations`: JSON response shaping.
- `app/services`: domain services such as version recorders, wiki commit,
YouTube sync, and similarity calculation.
- `config/routes.rb`: public API routes.
- `db/migrate`: migrations.
- `db/schema.rb`: schema snapshot.
- `lib/tasks`: custom Rake tasks.
- `spec`: RSpec tests.
Before changing behavior, inspect the matching route, controller, model,
service, representation, and spec.
## Ruby style
- Prefer precise, minimal changes.
- Use single quotes unless interpolation or escaping makes double quotes better.
- Do not put a space before Ruby method-call parentheses.
- Never put a line break immediately before `)` in Ruby.
- Do not use `%w` or `%i` in new Ruby code.
- Never write a Ruby line longer than 99 characters.
- Aim to keep Ruby lines within 79 characters where practical.
- For small Ruby method definitions that take keyword arguments, match the
local no-parentheses style when nearby code uses it.
- Treat Ruby hash `{ ... }` style and Ruby block `{ ... }` style as separate
rules.
- Do not format Ruby hashes like Ruby blocks.
- For Ruby hashes, keep the closing `}` on the same line as the final pair.
- Keep the first pair on the same line as `{` by default.
- If the hash would exceed the line limit, break after `{` and indent pairs
by 4 spaces.
- Put one logical pair per line when the expression would otherwise become
dense.
- For Ruby arrays, never put whitespace or a line break immediately before `]`.
- Keep the first element on the same line as `[` by default.
- If an array would exceed the line limit, break after `[` and indent
elements by 4 spaces.
- For Ruby blocks, use 2-space indentation for the block body.
- Keep comments short and useful; avoid narrating obvious code.
- Do not add production dependencies without approval.
## Authentication and authorization
- Authentication is handled through the `X-Transfer-Code` header in
`ApplicationController#authenticate_user`.
- `current_user` is set by looking up `User.inheritance_code`.
- Do not bypass or weaken the `X-Transfer-Code` flow unless the task
explicitly changes authentication.
- Unauthenticated write actions should return `:unauthorized` consistently
with existing controllers.
- Role checks use `User` enum roles: `guest`, `member`, and `admin`.
- Use `current_user.gte_member?` for member-or-admin write permissions where
existing controllers do so.
- Use `current_user.admin?` only for admin-only paths, such as tag child relationship changes.
- Do not replace role checks with looser presence checks.
## BAN and IP BAN
- `ApplicationController` runs these before actions in order:
- `reject_banned_ip_address!`
- `authenticate_user`
- `reject_banned_user!`
- User and IP bans use `banned_at`, not a boolean `banned` column.
- `User#banned?` and `IpAddress#banned?` check `banned_at.present?`.
- Do not weaken BAN or IP BAN behavior.
- If changing request authentication or controller before actions, add or
update request specs covering banned users and banned IP addresses.
## RSpec
- Prefer RSpec for new backend tests.
- Put API behavior coverage under `spec/requests`.
- Put model behavior under `spec/models`.
- Put service behavior under `spec/services`.
- Put Rake task coverage under `spec/tasks`.
- `spec/rails_helper.rb` loads `spec/support/**/*.rb`.
- Request specs include `AuthHelper` and `JsonHelper`.
- `AuthHelper#sign_in_as(user)` stubs
`ApplicationController#current_user`; use it when matching existing
request spec style.
- Add or update request specs for API behavior changes, especially status
codes, permissions, response shape, and version conflict behavior.
## Migrations
- Keep migrations and `db/schema.rb` consistent.
- Use reversible migrations where practical; otherwise define explicit `up` and `down`.
- For data backfills inside migrations, follow the existing pattern of
defining migration-local `ActiveRecord::Base` classes with
`self.table_name`.
- Preserve existing indexes, foreign keys, check constraints, and null constraints.
- Be careful with MySQL-specific options already present in migrations, such as `after:`.
- Do not edit old migrations just to change current behavior unless
explicitly requested; add a new migration.
## Version tables
- Versioned records include posts, tags, nico tags, and wiki pages.
- Current records have `version_no`; version tables have positive
`version_no` with unique indexes scoped to the parent record.
- Version event types are `create`, `update`, `discard`, and `restore`.
- Version rows are readonly through the `VersionRecord` concern.
- Use the existing recorder services instead of manually inserting version
rows in application code:
- `PostVersionRecorder`
- `TagVersionRecorder`
- `NicoTagVersionRecorder`
- `WikiVersionRecorder`
- `TagVersioning`
- `VersionRecorder` locks the current record, validates sequence consistency,
skips unchanged update snapshots, creates the next version row, and updates
the record `version_no`.
- Do not update versioned records without considering whether a version snapshot must be created.
- For optimistic concurrency paths, preserve `base_version_no`, `force`, and
`merge` semantics and cover conflicts in request specs.
## Domain cautions
- Posts have tag snapshots, parent post implications, original-created ranges,
viewed state, and version conflict behavior.
- Tags have canonical names, aliases through `TagName`, categories, parent
implications, discard behavior, and version snapshots.
- Nico tags have separate relation/version behavior; do not treat them like
normal editable tags without checking existing code.
- Wiki pages involve page content, revisions/history, version rows,
title/tag-name behavior, and diff/restore paths.
- Materials, theatres, and comments have user and permission checks; inspect
the controller before changing them.
## API responses
- Use representation classes under `app/representations` when existing endpoints do.
- Keep response keys consistent with existing JSON contracts.
- Frontend code expects camelCase conversion client-side, while Rails params
and JSON keys are generally snake_case.
- Preserve existing HTTP status conventions:
`:unauthorized` for no user, `:forbidden` for insufficient role or banned
user, `:not_found` for missing records, and `:unprocessable_entity` for
validation failures.
- For diagnostic or internal helper JSON, prefer a deliberately light response
shape over full representation classes when callers only need identifiers,
labels, URLs, or weights.
## Active Record performance
- When a controller action serializes nested associations, preload the
associations it will touch instead of allowing N+1 queries.
- Be sensitive to N+1 queries in all backend work.
- Avoid introducing N+1 queries, and proactively fix existing N+1 issues when
you find them in the code path you are editing.
- When an association may already be preloaded, prefer loaded-association
checks that reuse the preloaded data without losing the efficient database
path.
## Files to avoid in routine work
- Do not inspect or edit `tmp/`, `log/`, `storage/`, `vendor/`, or dependency
directories unless explicitly needed.
- Do not modify generated schema or migration output without the corresponding
migration when schema changes are made.
-2
ファイルの表示
@@ -69,5 +69,3 @@ gem 'discard'
gem "rspec-rails", "~> 8.0", :groups => [:development, :test] gem "rspec-rails", "~> 8.0", :groups => [:development, :test]
gem 'aws-sdk-s3', require: false gem 'aws-sdk-s3', require: false
gem 'rails-i18n', '~> 8.0.0'
-4
ファイルの表示
@@ -306,9 +306,6 @@ GEM
rails-html-sanitizer (1.6.2) rails-html-sanitizer (1.6.2)
loofah (~> 2.21) loofah (~> 2.21)
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
rails-i18n (8.0.2)
i18n (>= 0.7, < 2)
railties (>= 8.0.0, < 9)
railties (8.0.2) railties (8.0.2)
actionpack (= 8.0.2) actionpack (= 8.0.2)
activesupport (= 8.0.2) activesupport (= 8.0.2)
@@ -480,7 +477,6 @@ DEPENDENCIES
puma (>= 5.0) puma (>= 5.0)
rack-cors rack-cors
rails (~> 8.0.2) rails (~> 8.0.2)
rails-i18n (~> 8.0.0)
rspec-rails (~> 8.0) rspec-rails (~> 8.0)
rubocop-rails-omakase rubocop-rails-omakase
sprockets-rails sprockets-rails
+3 -66
ファイルの表示
@@ -1,19 +1,14 @@
class ApplicationController < ActionController::API class ApplicationController < ActionController::API
rescue_from ActiveRecord::RecordInvalid, with: :render_record_invalid
rescue_from ActiveRecord::RecordNotUnique, with: :render_record_not_unique
before_action :reject_banned_ip_address!
before_action :authenticate_user before_action :authenticate_user
before_action :reject_banned_user!
def current_user = @current_user def current_user
@current_user
end
private private
def authenticate_user def authenticate_user
code = request.headers['X-Transfer-Code'] || request.headers['HTTP_X_TRANSFER_CODE'] code = request.headers['X-Transfer-Code'] || request.headers['HTTP_X_TRANSFER_CODE']
return if code.blank?
@current_user = User.find_by(inheritance_code: code) @current_user = User.find_by(inheritance_code: code)
end end
@@ -27,62 +22,4 @@ class ApplicationController < ActionController::API
s.in?(['', '1', 'true', 'on', 'yes']) s.in?(['', '1', 'true', 'on', 'yes'])
end end
end end
def render_bad_request message = 'リクエストが不正です.'
render json: { type: 'bad_request',
message:,
errors: { },
base_errors: [message] },
status: :bad_request
end
def render_unprocessable_entity message = '入力を確認してください.', field: nil
render_validation_error(fields: field ? { field => [message] } : { },
base: field ? [] : [message])
end
def render_record_invalid error
render_validation_error error.record
end
def render_record_not_unique _error = nil
render_validation_error base: ['すでに存在してゐます.']
end
def reject_banned_ip_address!
ip_address = IpAddress.find_by(ip_address: IPAddr.new(request.remote_ip).hton)
return unless ip_address&.banned?
head :forbidden
end
def reject_banned_user!
return unless current_user&.banned?
head :forbidden
end
def render_validation_error record = nil, fields: { }, base: [], status: :unprocessable_entity
errors = { }
if record
record.errors.each do |error|
errors[error.attribute] ||= []
errors[error.attribute] << error.message
end
end
fields.each do |attr, messages|
errors[attr.to_sym] ||= []
errors[attr.to_sym].concat(Array(messages))
end
base_errors = Array(base) + Array(errors.delete(:base))
render json: { type: 'validation_error',
message: '入力内容を確認してください.',
errors:,
base_errors: },
status:
end
end end
+3 -7
ファイルの表示
@@ -2,8 +2,7 @@ class DeerjikistsController < ApplicationController
def show def show
platform = params[:platform].to_s.strip platform = params[:platform].to_s.strip
code = params[:code].to_s.strip code = params[:code].to_s.strip
return render_bad_request('platform は必須です.') if platform.blank? return head :bad_request if platform.blank? || code.blank?
return render_bad_request('code は必須です.') if code.blank?
deerjikist = Deerjikist deerjikist = Deerjikist
.joins(:tag) .joins(:tag)
@@ -23,9 +22,7 @@ class DeerjikistsController < ApplicationController
platform = params[:platform].to_s.strip platform = params[:platform].to_s.strip
code = params[:code].to_s.strip code = params[:code].to_s.strip
tag_id = params[:tag_id].to_i tag_id = params[:tag_id].to_i
return render_bad_request('platform は必須です.') if platform.blank? return head :bad_request if platform.blank? || code.blank? || tag_id <= 0
return render_bad_request('code は必須です.') if code.blank?
return render_bad_request('tag_id が不正です.') if tag_id <= 0
deerjikist = Deerjikist.find_or_initialize_by(platform:, code:).tap do |d| deerjikist = Deerjikist.find_or_initialize_by(platform:, code:).tap do |d|
d.tag_id = tag_id d.tag_id = tag_id
@@ -41,8 +38,7 @@ class DeerjikistsController < ApplicationController
platform = params[:platform].to_s.strip platform = params[:platform].to_s.strip
code = params[:code].to_s.strip code = params[:code].to_s.strip
return render_bad_request('platform は必須です.') if platform.blank? return head :bad_request if platform.blank? || code.blank?
return render_bad_request('code は必須です.') if code.blank?
Deerjikist.find([platform, code]).destroy! Deerjikist.find([platform, code]).destroy!
+4 -12
ファイルの表示
@@ -40,11 +40,7 @@ class MaterialsController < ApplicationController
tag_name_raw = params[:tag].to_s.strip tag_name_raw = params[:tag].to_s.strip
file = params[:file] file = params[:file]
url = params[:url].to_s.strip.presence url = params[:url].to_s.strip.presence
return render_unprocessable_entity('タグは必須です.', field: :tag) if tag_name_raw.blank? return head :bad_request if tag_name_raw.blank? || (file.blank? && url.blank?)
if file.blank? && url.blank?
return render_validation_error fields: { file: ['ファイルまたは URL は必須です.'],
url: ['ファイルまたは URL は必須です.'] }
end
tag_name = TagName.find_undiscard_or_create_by!(name: tag_name_raw) tag_name = TagName.find_undiscard_or_create_by!(name: tag_name_raw)
tag = tag_name.tag tag = tag_name.tag
@@ -58,7 +54,7 @@ class MaterialsController < ApplicationController
if material.save if material.save
render json: MaterialRepr.base(material, host: request.base_url), status: :created render json: MaterialRepr.base(material, host: request.base_url), status: :created
else else
render_validation_error material render json: { errors: material.errors.full_messages }, status: :unprocessable_entity
end end
end end
@@ -72,11 +68,7 @@ class MaterialsController < ApplicationController
tag_name_raw = params[:tag].to_s.strip tag_name_raw = params[:tag].to_s.strip
file = params[:file] file = params[:file]
url = params[:url].to_s.strip.presence url = params[:url].to_s.strip.presence
return render_unprocessable_entity('タグは必須です.', field: :tag) if tag_name_raw.blank? return head :bad_request if tag_name_raw.blank? || (file.blank? && url.blank?)
if file.blank? && url.blank?
return render_validation_error fields: { file: ['ファイルまたは URL は必須です.'],
url: ['ファイルまたは URL は必須です.'] }
end
tag_name = TagName.find_undiscard_or_create_by!(name: tag_name_raw) tag_name = TagName.find_undiscard_or_create_by!(name: tag_name_raw)
tag = tag_name.tag tag = tag_name.tag
@@ -92,7 +84,7 @@ class MaterialsController < ApplicationController
if material.save if material.save
render json: MaterialRepr.base(material, host: request.base_url) render json: MaterialRepr.base(material, host: request.base_url)
else else
render_validation_error material render json: { errors: material.errors.full_messages }, status: :unprocessable_entity
end end
end end
+18 -81
ファイルの表示
@@ -1,69 +1,26 @@
class NicoTagsController < ApplicationController class NicoTagsController < ApplicationController
def index def index
name = params[:name].presence limit = (params[:limit] || 20).to_i
linked_tag = params[:linked_tag].presence cursor = params[:cursor].presence
link_status = params[:link_status].presence
order = params[:order].to_s.split(':', 2).map(&:strip)
order[0] = 'updated_at' unless order[0].in?(['name', 'created_at', 'updated_at'])
unless order[1].in?(['asc', 'desc'])
order[1] = order[0] == 'name' ? 'asc' : 'desc'
end
page = (params[:page].presence || 1).to_i
limit = (params[:limit].presence || 20).to_i
page = 1 if page < 1
limit = 1 if limit < 1
post_tag_max_sql =
PostTag
.select('tag_id, MAX(created_at) AS max_created_at')
.group('tag_id')
.to_sql
q = Tag.nico_tags q = Tag.nico_tags
.joins(:tag_name)
.joins("LEFT JOIN (#{ post_tag_max_sql }) post_tag_max " \
'ON post_tag_max.tag_id = tags.id')
.includes(:tag_name, tag_name: :wiki_page, linked_tags: { tag_name: :wiki_page }) .includes(:tag_name, tag_name: :wiki_page, linked_tags: { tag_name: :wiki_page })
q = q.where('tag_names.name LIKE ?', "%#{ name }%") if name .order(updated_at: :desc)
if linked_tag q = q.where('tags.updated_at < ?', Time.iso8601(cursor)) if cursor
linked_tag_ids =
Tag
.joins(:tag_name)
.where('tag_names.name LIKE ?', "%#{ linked_tag }%")
.pluck(:id)
linked_nico_tag_ids = NicoTagRelation.where(tag_id: linked_tag_ids).pluck(:nico_tag_id)
q = q.where(id: linked_nico_tag_ids)
end
if link_status.in?(['linked', 'unlinked'])
exists_sql =
'EXISTS (SELECT 1 FROM nico_tag_relations ' \
'WHERE nico_tag_relations.nico_tag_id = tags.id)'
q = link_status == 'linked' ? q.where(exists_sql) : q.where("NOT #{ exists_sql }")
end
count = q.count tags = q.limit(limit + 1).to_a
sort_sql =
case order[0] next_cursor = nil
when 'name' if tags.size > limit
'tag_names.name' next_cursor = tags.last.updated_at.iso8601(6)
when 'updated_at' tags = tags.first(limit)
'post_tag_max.max_created_at'
else
"tags.#{ order[0] }"
end end
tags = q.reselect('tags.*',
Arel.sql('post_tag_max.max_created_at AS recent_post_tag_created_at'))
.order(Arel.sql("#{ sort_sql } #{ order[1] }, tags.id #{ order[1] }"))
.limit(limit)
.offset((page - 1) * limit)
.to_a
render json: { tags: tags.map { |tag| render json: { tags: tags.map { |tag|
TagRepr.base(tag).merge( TagRepr.base(tag).merge(linked_tags: tag.linked_tags.map { |lt|
recent_post_tag_created_at: tag.recent_post_tag_created_at, TagRepr.base(lt)
linked_tags: tag.linked_tags.map { |lt| TagRepr.base(lt) }) })
}, count: } }, next_cursor: }
end end
def update def update
@@ -73,18 +30,14 @@ class NicoTagsController < ApplicationController
id = params[:id].to_i id = params[:id].to_i
tag = Tag.find(id) tag = Tag.find(id)
return render_bad_request('ニコニコ・タグを指定してください.') unless tag.nico? return head :bad_request unless tag.nico?
linked_tag_names = params[:tags].to_s.split linked_tag_names = params[:tags].to_s.split
linked_tags = nil linked_tags = Tag.normalise_tags(linked_tag_names, with_tagme: false,
with_no_deerjikist: false)
return head :bad_request if linked_tags.any? { |t| t.nico? }
ApplicationRecord.transaction do ApplicationRecord.transaction do
linked_tags = Tag.normalise_tags!(linked_tag_names, with_tagme: false,
with_no_deerjikist: false)
if linked_tags.any? { |t| t.nico? }
raise Tag::NicoTagNormalisationError
end
TagVersioning.record_tag_snapshots!(linked_tags, created_by_user: current_user) TagVersioning.record_tag_snapshots!(linked_tags, created_by_user: current_user)
tag.linked_tags = linked_tags tag.linked_tags = linked_tags
@@ -94,21 +47,5 @@ class NicoTagsController < ApplicationController
end end
render json: tag.linked_tags.map { |t| TagRepr.base(t) }, status: :ok render json: tag.linked_tags.map { |t| TagRepr.base(t) }, status: :ok
rescue Tag::NicoTagNormalisationError
render_validation_error fields: { tags: ['ニコニコ・タグ同士は連携できません.'] }
rescue ActiveRecord::RecordInvalid => e
render_nico_tag_form_record_invalid e.record
end
private
def render_nico_tag_form_record_invalid record
if record.is_a?(TagName) || record.is_a?(Tag)
render_validation_error fields: { tags: record.errors.full_messages.map { |message|
"タグ名 “#{ record.name }”: #{ message }"
} }
else
render_validation_error record
end
end end
end end
+28 -356
ファイルの表示
@@ -44,8 +44,7 @@ class PostsController < ApplicationController
filtered_posts filtered_posts
.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(tags: [:materials, { tag_name: :wiki_page }])
tags: [:deerjikists, :materials, { 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
@@ -96,9 +95,7 @@ class PostsController < ApplicationController
end end
def random def random
post = filtered_posts.preload(:uploaded_user, :parents, :children, post = filtered_posts.preload(tags: [:materials, { tag_name: :wiki_page }])
tags: [:deerjikists, :materials, { tag_name: :wiki_page }])
.with_attached_thumbnail
.order('RAND()') .order('RAND()')
.first .first
return head :not_found unless post return head :not_found unless post
@@ -107,25 +104,12 @@ class PostsController < ApplicationController
end end
def show def show
post = post = Post.includes(tags: [:materials, { tag_name: :wiki_page }]).find_by(id: params[:id])
Post
.includes(:uploaded_user, :parents, :children,
tags: [:deerjikists, :materials, { tag_name: :wiki_page }])
.with_attached_thumbnail
.find_by(id: params[:id])
return head :not_found unless post return head :not_found unless post
parent_posts = post.parents.with_attached_thumbnail.order(:id).to_a render json: PostRepr.base(post, current_user)
child_posts = post.children.with_attached_thumbnail.order(:id).to_a .merge(tags: build_tag_tree_for(post.tags),
sibling_posts = sibling_posts_by_parent(parent_posts.map(&:id)) related: post.related(limit: 20))
related = post.related(limit: 20).to_a
render json: PostRepr.detail(post, current_user,
parent_posts:,
child_posts:,
sibling_posts:,
related:)
.merge(tags: build_tag_tree_for(post.tags))
end end
def create def create
@@ -139,36 +123,28 @@ class PostsController < ApplicationController
tag_names = params[:tags].to_s.split tag_names = params[:tags].to_s.split
original_created_from = params[:original_created_from] original_created_from = params[:original_created_from]
original_created_before = params[:original_created_before] original_created_before = params[:original_created_before]
parent_post_ids = parse_parent_post_ids
post = Post.new(title:, url:, thumbnail_base: nil, uploaded_user: current_user, post = Post.new(title:, url:, thumbnail_base: nil, uploaded_user: current_user,
original_created_from:, original_created_before:) original_created_from:, original_created_before:)
post.thumbnail.attach(thumbnail) if thumbnail.present? post.thumbnail.attach(thumbnail)
ApplicationRecord.transaction do ApplicationRecord.transaction do
post.save! post.save!
tags = Tag.normalise_tags(tag_names)
tags = Tag.normalise_tags!(tag_names)
TagVersioning.record_tag_snapshots!(tags, created_by_user: current_user) TagVersioning.record_tag_snapshots!(tags, created_by_user: current_user)
tags = Tag.expand_parent_tags(tags) tags = Tag.expand_parent_tags(tags)
sync_post_tags!(post, tags) sync_post_tags!(post, tags)
sync_parent_posts!(post, parent_post_ids)
post.resized_thumbnail! post.resized_thumbnail!
PostVersionRecorder.record!(post:, event_type: :create, created_by_user: current_user) PostVersionRecorder.record!(post:, event_type: :create, created_by_user: current_user)
end end
post.reload post.reload
render json: PostRepr.base(post), status: :created render json: PostRepr.base(post), status: :created
rescue ActiveRecord::RecordInvalid
render json: { errors: post.errors.full_messages }, status: :unprocessable_entity
rescue Tag::NicoTagNormalisationError rescue Tag::NicoTagNormalisationError
render_validation_error fields: { tags: 'ニコニコ・タグは直接指定できません.' } head :bad_request
rescue ArgumentError => e
render_validation_error fields: { parent_post_ids: [e.message] }
rescue ActiveRecord::RecordInvalid => e
render_post_form_record_invalid e.record
end end
def viewed def viewed
@@ -189,76 +165,35 @@ class PostsController < ApplicationController
return head :unauthorized unless current_user return head :unauthorized unless current_user
return head :forbidden unless current_user.gte_member? return head :forbidden unless current_user.gte_member?
force = bool?(:force)
merge = bool?(:merge)
return render_bad_request('force と merge は同時に指定できません.') if force && merge
base_version_no = parse_base_version_no
return render_bad_request('base_version_no は必須です.') if !(force) && !(base_version_no)
title = params[:title].presence title = params[:title].presence
tag_names = params[:tags].to_s.split tag_names = params[:tags].to_s.split
original_created_from = params[:original_created_from] original_created_from = params[:original_created_from]
original_created_before = params[:original_created_before] original_created_before = params[:original_created_before]
parent_post_ids = parse_parent_post_ids
post = nil post = Post.find(params[:id].to_i)
conflict_json = nil
ApplicationRecord.transaction do ApplicationRecord.transaction do
post = Post.lock.find(params[:id].to_i) PostVersionRecorder.ensure_snapshot!(post, created_by_user: current_user)
base_version = nil post.update!(title:, original_created_from:, original_created_before:)
base_snapshot = nil
current_snapshot = nil
unless force
base_version = post.post_versions.find_by!(version_no: base_version_no)
base_snapshot = post_snapshot_from_version(base_version) normalised_tags = Tag.normalise_tags(tag_names, with_tagme: false)
current_snapshot = post_snapshot_from_record(post) TagVersioning.record_tag_snapshots!(normalised_tags, created_by_user: current_user)
tags = post.tags.nico.to_a + normalised_tags
tags = Tag.expand_parent_tags(tags)
sync_post_tags!(post, tags)
PostVersionRecorder.record!(post:, event_type: :update, created_by_user: current_user)
end end
incoming_snapshot = post_incoming_snapshot(title:,
original_created_from:,
original_created_before:,
tag_names:,
parent_post_ids:)
snapshot_to_apply =
if force || post.version_no == base_version_no || current_snapshot == base_snapshot
incoming_snapshot
else
changes = post_snapshot_changes(base_snapshot, current_snapshot, incoming_snapshot)
conflicts = changes.select { |change| change[:conflict] }
if merge && conflicts.empty?
merge_post_snapshots(base_snapshot, current_snapshot, incoming_snapshot)
else
conflict_json = post_conflict_json(post:,
base_version_no:,
base_snapshot:,
current_snapshot:,
incoming_snapshot:,
changes:,
conflicts:)
raise ActiveRecord::Rollback
end
end
apply_post_snapshot!(post, snapshot_to_apply)
end
return render json: conflict_json, status: :conflict if conflict_json
post.reload post.reload
json = PostRepr.base(post, current_user) json = post.as_json
json['tags'] = build_tag_tree_for(post.tags) json['tags'] = build_tag_tree_for(post.tags)
render json:, status: :ok render json:, status: :ok
rescue ActiveRecord::RecordInvalid
render json: post.errors, status: :unprocessable_entity
rescue Tag::NicoTagNormalisationError rescue Tag::NicoTagNormalisationError
render_validation_error fields: { tags: ['ニコニコ・タグは直接指定できません.'] } head :bad_request
rescue ArgumentError => e
render_validation_error fields: { parent_post_ids: [e.message] }
rescue ActiveRecord::RecordInvalid => e
render_post_form_record_invalid e.record
end end
def changes def changes
@@ -276,7 +211,7 @@ class PostsController < ApplicationController
pts = pts.where(post_id: id) if id.present? pts = pts.where(post_id: id) if id.present?
pts = pts.where(tag_id:) if tag_id.present? pts = pts.where(tag_id:) if tag_id.present?
pts = pts.includes(:post, :created_user, :deleted_user, pts = pts.includes(:post, :created_user, :deleted_user,
tag: [:deerjikists, :materials, { tag_name: :wiki_page }]) tag: [:materials, { tag_name: :wiki_page }])
events = [] events = []
pts.each do |pt| pts.each do |pt|
@@ -401,7 +336,7 @@ class PostsController < ApplicationController
return nil unless tag return nil unless tag
if path.include?(tag_id) if path.include?(tag_id)
return TagRepr.inline(tag).merge(children: []) return TagRepr.base(tag).merge(children: [])
end end
if memo.key?(tag_id) if memo.key?(tag_id)
@@ -413,272 +348,9 @@ class PostsController < ApplicationController
children = child_ids.filter_map { |cid| build_node.(cid, new_path) } children = child_ids.filter_map { |cid| build_node.(cid, new_path) }
memo[tag_id] = TagRepr.inline(tag).merge(children:) memo[tag_id] = TagRepr.base(tag).merge(children:)
end end
root_ids.filter_map { |id| build_node.call(id, []) } root_ids.filter_map { |id| build_node.call(id, []) }
end end
def sibling_posts_by_parent parent_post_ids
return { } if parent_post_ids.blank?
implications =
PostImplication
.where(parent_post_id: parent_post_ids)
.includes(post: { thumbnail_attachment: :blob })
.order(:parent_post_id, :post_id)
implications.group_by(&:parent_post_id).transform_values { |items|
items.map(&:post)
}
end
def parse_parent_post_ids
raise ArgumentError, 'parent_post_ids は必須です.' unless params.key?(:parent_post_ids)
params[:parent_post_ids].to_s.split.map { |token|
id = Integer(token, exception: false)
raise ArgumentError, "親投稿 Id. が不正です: #{ token }" if id.nil? || id <= 0
id
}.uniq
end
def sync_parent_posts! post, parent_post_ids
if parent_post_ids.include?(post.id)
post.errors.add :parent_post_ids, '自分自身を親投稿にはできません.'
raise ActiveRecord::RecordInvalid, post
end
existing_ids = Post.where(id: parent_post_ids).pluck(:id)
missing_ids = parent_post_ids - existing_ids
if missing_ids.present?
post.errors.add :parent_post_ids,
"存在しない親投稿 Id. があります: #{ missing_ids.join(' ') }"
raise ActiveRecord::RecordInvalid, post
end
current_ids = post.parent_posts.pluck(:id)
ids_to_add = parent_post_ids - current_ids
ids_to_remove = current_ids - parent_post_ids
PostImplication.where(post_id: post.id, parent_post_id: ids_to_remove).delete_all
ids_to_add.each do |parent_post_id|
PostImplication.create_or_find_by!(post_id: post.id, parent_post_id:)
end
end
def parse_base_version_no
version_no = Integer(params[:base_version_no], exception: false)
if version_no&.positive?
version_no
else
nil
end
end
def post_snapshot_from_version version
{ title: version.title,
original_created_from: snapshot_time(version.original_created_from),
original_created_before: snapshot_time(version.original_created_before),
tag_names: editable_tag_names_from_version(version),
parent_post_ids: snapshot_parent_post_ids_from_version(version) }
end
def editable_tag_names_from_version version
version.tags.to_s.split.reject { |name| name.downcase.start_with?('nico:') }.sort
end
def post_snapshot_from_record post
{ title: post.title,
original_created_from: snapshot_time(post.original_created_from),
original_created_before: snapshot_time(post.original_created_before),
tag_names: editable_tag_names_from_post(post),
parent_post_ids: post.parent_posts.order(:id).pluck(:id) }
end
def editable_tag_names_from_post post
post.tags.not_nico.joins(:tag_name).order('tag_names.name').pluck('tag_names.name')
end
def post_incoming_snapshot title:, original_created_from:, original_created_before:,
tag_names:, parent_post_ids:
{ title:,
original_created_from: snapshot_time(original_created_from),
original_created_before: snapshot_time(original_created_before),
tag_names: incoming_tag_names_for_snapshot(tag_names),
parent_post_ids: parent_post_ids.sort }
end
def snapshot_parent_post_ids_from_version version
if version.respond_to?(:parent_post_ids)
version.parent_post_ids.to_s.split.map { |id| id.to_i }.sort
elsif version.respond_to?(:parent_id) && version.parent_id
[version.parent_id]
else
[]
end
end
def snapshot_time value
return nil if value.blank?
value = Time.zone.parse(value.to_s) if value in String
value&.in_time_zone&.iso8601(6)
rescue ArgumentError, TypeError
value.to_s
end
def incoming_tag_names_for_snapshot raw_tag_names
tags = Tag.normalise_tags!(raw_tag_names, with_tagme: false)
Tag.expand_parent_tags(tags).map(&:name).uniq.sort
end
def post_conflict_json post:, base_version_no:, base_snapshot:,
current_snapshot:, incoming_snapshot:, changes:, conflicts:
{ error: 'conflict',
message: '競合が発生しました.',
post_id: post.id,
base_version_no:,
current_version_no: post.version_no,
base: base_snapshot,
current: current_snapshot,
mine: incoming_snapshot,
changes:,
conflicts:,
mergeable: conflicts.empty? }
end
def post_snapshot_changes base_snapshot, current_snapshot, incoming_snapshot
[scalar_snapshot_change(:title, 'タイトル',
base_snapshot, current_snapshot, incoming_snapshot),
scalar_snapshot_change(:original_created_from, 'オリジナルの作成日時(以降)',
base_snapshot, current_snapshot, incoming_snapshot),
scalar_snapshot_change(:original_created_before, 'オリジナルの作成日時(より前)',
base_snapshot, current_snapshot, incoming_snapshot),
set_snapshot_change(:tag_names, 'タグ',
base_snapshot, current_snapshot, incoming_snapshot),
set_snapshot_change(:parent_post_ids, '親投稿',
base_snapshot, current_snapshot, incoming_snapshot)].compact
end
def scalar_snapshot_change field, label, base_snapshot, current_snapshot, incoming_snapshot
base = base_snapshot[field]
current = current_snapshot[field]
mine = incoming_snapshot[field]
return nil if current == base && mine == base
{ field:, label:, base:, current:, mine:,
changed_by_current: current != base,
changed_by_me: mine != base,
conflict: scalar_snapshot_conflict?(base, current, mine) }
end
def scalar_snapshot_conflict? base, current, mine
current != base && mine != base && current != mine
end
def set_snapshot_change field, label, base_snapshot, current_snapshot, incoming_snapshot
base = base_snapshot[field].to_a
current = current_snapshot[field].to_a
mine = incoming_snapshot[field].to_a
added_by_current = current - base
removed_by_current = base - current
added_by_me = mine - base
removed_by_me = base - mine
if (added_by_current.empty? &&
removed_by_current.empty? &&
added_by_me.empty? &&
removed_by_me.empty?)
return nil
end
{ field:, label:, base:, current:, mine:, added_by_current:, removed_by_current:,
added_by_me:, removed_by_me:,
changed_by_current: added_by_current.present? || removed_by_current.present?,
changed_by_me: added_by_me.present? || removed_by_me.present?,
conflict: set_snapshot_conflict?(added_by_current:,
removed_by_current:,
added_by_me:,
removed_by_me:) }
end
def set_snapshot_conflict? added_by_current:, removed_by_current:,
added_by_me:, removed_by_me:
(added_by_current & removed_by_me).present? || (removed_by_current & added_by_me).present?
end
def apply_post_snapshot! post, snapshot
PostVersionRecorder.ensure_snapshot!(post, created_by_user: current_user)
post.update!(title: snapshot[:title],
original_created_from: snapshot[:original_created_from],
original_created_before: snapshot[:original_created_before])
editable_tags = Tag.normalise_tags!(snapshot[:tag_names], with_tagme: false)
TagVersioning.record_tag_snapshots!(editable_tags, created_by_user: current_user)
readonly_tags = post.tags.nico.to_a
tags = readonly_tags + editable_tags
tags = Tag.expand_parent_tags(tags)
sync_post_tags!(post, tags)
sync_parent_posts!(post, snapshot[:parent_post_ids])
PostVersionRecorder.record!(post:, event_type: :update, created_by_user: current_user)
end
def merge_post_snapshots base_snapshot, current_snapshot, incoming_snapshot
[:title, :original_created_from, :original_created_before].map {
[_1, merge_scalar_snapshot_value(base_snapshot[_1],
current_snapshot[_1],
incoming_snapshot[_1])]
}.to_h.merge([:tag_names, :parent_post_ids].map {
[_1, merge_set_snapshot_value(base_snapshot[_1],
current_snapshot[_1],
incoming_snapshot[_1])]
}.to_h)
end
def merge_scalar_snapshot_value base, current, mine
return mine if current == base
return current if mine == base || current == mine
raise ArgumentError, '競合してゐる項目はマージできません.'
end
def merge_set_snapshot_value base, current, mine
base = base.to_a
current = current.to_a
mine = mine.to_a
added_by_current = current - base
removed_by_current = base - current
added_by_me = mine - base
removed_by_me = base - mine
merged = base + added_by_current + added_by_me
merged -= removed_by_current
merged -= removed_by_me
merged.uniq.sort
end
def render_post_form_record_invalid record
if record.is_a?(TagName) || record.is_a?(Tag)
render_validation_error fields: { tags: record.errors.full_messages.map { |message|
"タグ名 “#{ record.name }”: #{ message }"
} }
else
render_validation_error record
end
end
end end
+4 -8
ファイルの表示
@@ -4,7 +4,7 @@ class PreviewController < ApplicationController
return head :unauthorized unless current_user return head :unauthorized unless current_user
url = params[:url] url = params[:url]
return render_bad_request('URL は必須です.') unless url.present? return head :bad_request unless url.present?
unless url.start_with?(/http(s)?:\/\//) unless url.start_with?(/http(s)?:\/\//)
url = 'http://' + url url = 'http://' + url
@@ -16,7 +16,7 @@ class PreviewController < ApplicationController
render json: { title: title } render json: { title: title }
rescue => e rescue => e
render_bad_request(e.message) render json: { error: e.message }, status: :bad_request
end end
def thumbnail def thumbnail
@@ -25,7 +25,7 @@ class PreviewController < ApplicationController
return head :unauthorized unless current_user return head :unauthorized unless current_user
url = params[:url] url = params[:url]
return render_bad_request('URL は必須です.') if url.blank? return head :bad_request if url.blank?
unless url.start_with?(/http(s)?:\/\//) unless url.start_with?(/http(s)?:\/\//)
url = 'http://' + url url = 'http://' + url
@@ -40,11 +40,7 @@ class PreviewController < ApplicationController
File.delete(path) rescue nil File.delete(path) rescue nil
send_file image.path, type: 'image/png', disposition: 'inline' send_file image.path, type: 'image/png', disposition: 'inline'
else else
render json: { type: 'internal_server_error', render json: { error: 'Failed to generate thumbnail' }, status: :internal_server_error
message: 'サムネールを生成できませんでした.',
errors: { },
base_errors: ['サムネールを生成できませんでした.'] },
status: :internal_server_error
end end
end end
end end
+4 -6
ファイルの表示
@@ -5,12 +5,11 @@ class TagChildrenController < ApplicationController
parent_id = params[:parent_id] parent_id = params[:parent_id]
child_id = params[:child_id] child_id = params[:child_id]
return render_bad_request('parent_id は必須です.') if parent_id.blank? return head :bad_request if parent_id.blank? || child_id.blank?
return render_bad_request('child_id は必須です.') if child_id.blank?
parent = Tag.find(parent_id) parent = Tag.find(parent_id)
child = Tag.find(child_id) child = Tag.find(child_id)
return render_bad_request('ニコニコ・タグの階層は変更できません.') if parent.nico? || child.nico? return head :bad_request if parent.nico? || child.nico?
ApplicationRecord.transaction do ApplicationRecord.transaction do
TagVersioning.ensure_snapshot!(child, created_by_user: current_user) TagVersioning.ensure_snapshot!(child, created_by_user: current_user)
@@ -28,12 +27,11 @@ class TagChildrenController < ApplicationController
parent_id = params[:parent_id] parent_id = params[:parent_id]
child_id = params[:child_id] child_id = params[:child_id]
return render_bad_request('parent_id は必須です.') if parent_id.blank? return head :bad_request if parent_id.blank? || child_id.blank?
return render_bad_request('child_id は必須です.') if child_id.blank?
parent = Tag.find(parent_id) parent = Tag.find(parent_id)
child = Tag.find(child_id) child = Tag.find(child_id)
return render_bad_request('ニコニコ・タグの階層は変更できません.') if parent.nico? || child.nico? return head :bad_request if parent.nico? || child.nico?
ApplicationRecord.transaction do ApplicationRecord.transaction do
TagVersioning.ensure_snapshot!(child, created_by_user: current_user) TagVersioning.ensure_snapshot!(child, created_by_user: current_user)
+13 -102
ファイルの表示
@@ -1,7 +1,3 @@
require 'net/http'
require 'uri'
class TagsController < ApplicationController class TagsController < ApplicationController
def index def index
post_id = params[:post] post_id = params[:post]
@@ -168,7 +164,7 @@ class TagsController < ApplicationController
def show_by_name def show_by_name
name = params[:name].to_s.strip name = params[:name].to_s.strip
return render_bad_request('name は必須です.') if name.blank? return head :bad_request if name.blank?
tag = Tag.joins(:tag_name) tag = Tag.joins(:tag_name)
.includes(:tag_name, :materials, tag_name: :wiki_page) .includes(:tag_name, :materials, tag_name: :wiki_page)
@@ -186,52 +182,24 @@ 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
render json: { tag: TagRepr.base(tag), render json: DeerjikistRepr.many(tag.deerjikists)
deerjikists: DeerjikistRepr.many(tag.deerjikists) }
end end
def deerjikists_by_name def deerjikists_by_name
name = params[:name].to_s.strip name = params[:name].to_s.strip
return render_bad_request('name は必須です.') if name.blank? return head :bad_request if name.blank?
tag = Tag.joins(:tag_name) tag = Tag.joins(:tag_name)
.includes(:tag_name, tag_name: :wiki_page) .includes(:tag_name, tag_name: :wiki_page)
.find_by(tag_names: { name: }) .find_by(tag_names: { name: })
return head :not_found unless tag return head :not_found unless tag
render json: { tag: TagRepr.base(tag), render json: DeerjikistRepr.many(tag.deerjikists)
deerjikists: DeerjikistRepr.many(tag.deerjikists) }
end
def update_deerjikists
return head :unauthorized unless current_user
return head :forbidden unless current_user.gte_member?
tag = Tag.joins(:tag_name)
.includes(:tag_name, tag_name: :wiki_page)
.find_by(id: params[:id])
return head :not_found unless tag
ApplicationRecord.transaction do
tag.deerjikists = []
params[:_json].each.with_index do |item, i|
platform = item[:platform]
code = normalise_deerjikist_code(platform, item[:code])
deerjikist = Deerjikist.find_or_initialize_by(platform:, code:)
deerjikist.tag = tag
render_deerjikist_form_record_invalid(deerjikist, i) unless deerjikist.save
raise ActiveRecord::Rollback if performed?
end
end
return if performed?
render json: DeerjikistRepr.many(tag.reload.deerjikists)
end end
def materials_by_name def materials_by_name
name = params[:name].to_s.strip name = params[:name].to_s.strip
return render_bad_request('name は必須です.') if name.blank? return head :bad_request if name.blank?
tag = Tag.joins(:tag_name) tag = Tag.joins(:tag_name)
.includes(:tag_name, :materials, tag_name: :wiki_page) .includes(:tag_name, :materials, tag_name: :wiki_page)
@@ -250,16 +218,17 @@ class TagsController < ApplicationController
name = params[:name].to_s.strip name = params[:name].to_s.strip
category = params[:category].to_s.strip category = params[:category].to_s.strip
return render_unprocessable_entity('名前は必須です.', field: :name) if name.blank? return head :unprocessable_entity if name.blank? || category.blank?
return render_unprocessable_entity('カテゴリは必須です.', field: :category) if category.blank?
if name != tag.name && if name != tag.name &&
tag.in?([Tag.tagme, Tag.bot, Tag.no_deerjikist, Tag.video, Tag.niconico]) tag.in?([Tag.tagme, Tag.bot, Tag.no_deerjikist, Tag.video, Tag.niconico])
return render_unprocessable_entity('システム・タグの名称は変更できません.', field: :name) return render json: { error: 'システム・タグの名称は変更できません.' },
status: :unprocessable_entity
end end
if tag.nico? || category == 'nico' if tag.nico? || category == 'nico'
return render_unprocessable_entity('ニコタグは変更できません.', field: :category) return render json: { error: 'ニコタグは変更できません.' },
status: :unprocessable_entity
end end
alias_names = params[:aliases].to_s.split.uniq alias_names = params[:aliases].to_s.split.uniq
@@ -304,7 +273,8 @@ class TagsController < ApplicationController
tag = Tag.find(params[:id]) tag = Tag.find(params[:id])
if tag.nico? || (category.present? && category == 'nico') if tag.nico? || (category.present? && category == 'nico')
return render_unprocessable_entity('ニコタグは変更できません.', field: :category) return render json: { error: 'ニコタグは変更できません.' },
status: :unprocessable_entity
end end
ApplicationRecord.transaction do ApplicationRecord.transaction do
@@ -366,27 +336,8 @@ class TagsController < ApplicationController
end end
def update_aliases! tag, alias_names def update_aliases! tag, alias_names
alias_names = alias_names.uniq
affected_tags = [tag]
current_aliases = tag.tag_name.aliases.to_a current_aliases = tag.tag_name.aliases.to_a
current_aliases.each do |alias_tag_name|
next if alias_names.include?(alias_tag_name.name)
affected_tags << alias_tag_name.canonical&.tag
end
alias_names.each do |alias_name|
alias_tag_name = TagName.find_undiscard_or_create_by!(name: alias_name)
affected_tags << alias_tag_name.canonical&.tag
end
affected_tags.compact.uniq.each do |affected_tag|
TagVersioning.ensure_snapshot!(affected_tag, created_by_user: current_user)
end
current_aliases.each do |alias_tag_name| current_aliases.each do |alias_tag_name|
next if alias_names.include?(alias_tag_name.name) next if alias_names.include?(alias_tag_name.name)
@@ -397,14 +348,10 @@ class TagsController < ApplicationController
alias_tag_name = TagName.find_undiscard_or_create_by!(name: alias_name) alias_tag_name = TagName.find_undiscard_or_create_by!(name: alias_name)
alias_tag_name.update!(canonical: tag.tag_name) alias_tag_name.update!(canonical: tag.tag_name)
end end
affected_tags.compact.uniq.each do |affected_tag|
record_tag_version!(affected_tag, event_type: :update, created_by_user: current_user)
end
end end
def update_parent_tags! tag, parent_names def update_parent_tags! tag, parent_names
parent_tags = Tag.normalise_tags!(parent_names, with_tagme: false, parent_tags = Tag.normalise_tags(parent_names, with_tagme: false,
with_no_deerjikist: false, with_no_deerjikist: false,
deny_nico: true) deny_nico: true)
@@ -421,40 +368,4 @@ class TagsController < ApplicationController
TagImplication.create!(tag:, parent_tag:) TagImplication.create!(tag:, parent_tag:)
end end
end end
def normalise_deerjikist_code platform, code
return code if platform != 'youtube' || code[0] != '@'
url = "https://www.youtube.com/#{ code }"
html = Net::HTTP.get(URI(url))
canonical = html[
/<link rel="canonical" href="https:\/\/www\.youtube\.com\/channel\/(UC[a-zA-Z0-9_-]{22})"/,
1]
return canonical if canonical
html[/"channelId":"(UC[a-zA-Z0-9_-]{22})"/, 1] || html[/\bUC[a-zA-Z0-9_-]{22}\b/]
rescue
nil
end
def render_deerjikist_form_record_invalid deerjikist, index
fields = { }
deerjikist.errors.each do |error|
field =
case error.attribute
when :platform, :code
"deerjikists.#{ index }.#{ error.attribute }"
else
:deerjikists
end
fields[field] ||= []
fields[field] << error.full_message
end
render_validation_error fields:
end
end end
+3 -25
ファイルの表示
@@ -1,28 +1,21 @@
class TheatreCommentsController < ApplicationController class TheatreCommentsController < ApplicationController
def index def index
limit = params[:limit].to_i
limit = 20 if limit <= 0
no_gt = params[:no_gt].to_i no_gt = params[:no_gt].to_i
no_gt = 0 if no_gt < 0 no_gt = 0 if no_gt.negative?
comments = TheatreComment comments = TheatreComment
.where(theatre_id: params[:theatre_id]) .where(theatre_id: params[:theatre_id])
.where('no > ?', no_gt) .where('no > ?', no_gt)
.order(no: :desc) .order(no: :desc)
.limit(limit)
render json: comments.map { render json: comments.as_json(include: { user: { only: [:id, :name] } })
_1.as_json(include: { user: { only: [:id, :name] } })
.merge(content: _1.discarded? ? nil : _1.content, deleted: _1.discarded?)
}
end end
def create def create
return head :unauthorized unless current_user return head :unauthorized unless current_user
content = params[:content] content = params[:content]
return render_unprocessable_entity('本文は必須です.', field: :content) if content.blank? return head :unprocessable_entity if content.blank?
theatre = Theatre.find_by(id: params[:theatre_id]) theatre = Theatre.find_by(id: params[:theatre_id])
return head :not_found unless theatre return head :not_found unless theatre
@@ -36,19 +29,4 @@ class TheatreCommentsController < ApplicationController
render json: comment, status: :created render json: comment, status: :created
end end
def destroy
return head :unauthorized unless current_user
theatre_id = params[:theatre_id].to_i
no = params[:id].to_i
comment = TheatreComment.find_by(theatre_id:, no:)
return head :not_found unless comment
return head :forbidden unless comment.user == current_user
comment.discard!
head :no_content
end
end end
-22
ファイルの表示
@@ -1,22 +0,0 @@
class TheatreProgrammesController < ApplicationController
def index
limit = params[:limit].to_i
limit = 100 if limit <= 0
position_gt = params[:position_gt].to_i
position_gt = 0 if position_gt < 0
programmes = TheatreProgramme
.where(theatre_id: params[:theatre_id])
.where('position > ?', position_gt)
.includes(:post)
.order(position: :desc).limit(100)
.limit(limit)
render json: programmes.map { |programme|
programme.as_json.merge(post: { id: programme.post.id,
title: programme.post.title,
url: programme.post.url })
}
end
end
-22
ファイルの表示
@@ -1,22 +0,0 @@
class TheatreSkipEventsController < ApplicationController
def index
limit = params[:limit].to_i
limit = 50 if limit <= 0
events =
TheatreSkipEvent
.where(theatre_id: params[:theatre_id])
.includes(:post, tags: :tag_name)
.order(created_at: :desc)
.limit(limit)
render json: events.map { |event|
{ id: event.id,
theatre_id: event.theatre_id,
post: { id: event.post.id, title: event.post.title, url: event.post.url },
tags: event.tags.map { |tag| { id: tag.id, name: tag.name } },
programme_position: event.programme_position,
created_at: event.created_at }
}
end
end
+8 -113
ファイルの表示
@@ -31,7 +31,9 @@ class TheatresController < ApplicationController
post_started_at = theatre.current_post_started_at post_started_at = theatre.current_post_started_at
end end
render json: theatre_info_json(theatre, host_flg:, post_id:, post_started_at:) render json: {
host_flg:, post_id:, post_started_at:,
watching_users: theatre.watching_users.as_json(only: [:id, :name]) }
end end
def next_post def next_post
@@ -41,119 +43,12 @@ class TheatresController < ApplicationController
return head :not_found unless theatre return head :not_found unless theatre
return head :forbidden if theatre.host_user != current_user return head :forbidden if theatre.host_user != current_user
ApplicationRecord.transaction do post = Post.where("url LIKE '%nicovideo.jp%'")
theatre.lock! .or(Post.where("url LIKE '%youtube.com%'"))
TheatrePostAdvancer.call(theatre:) .order('RAND()')
end .first
theatre.update!(current_post: post, current_post_started_at: Time.current)
head :no_content head :no_content
end end
def skip_vote
return head :unauthorized unless current_user
theatre = Theatre.find_by(id: params[:id])
return head :not_found unless theatre
requested_post_id = params[:post_id].to_i
return head :unprocessable_entity if requested_post_id <= 0
skipped = false
conflicted = false
ApplicationRecord.transaction do
theatre.lock!
if theatre.current_post
TheatreWatchingUser.find_or_initialize_by(theatre:, user: current_user).tap {
_1.expires_at = 30.seconds.from_now
}.save!
if theatre.current_post_id != requested_post_id
conflicted = true
next
end
TheatreSkipVote.find_or_create_by!(theatre:, post_id: requested_post_id, user: current_user)
vote_status = skip_vote_status(theatre)
if vote_status[:votes_count] >= vote_status[:required_count]
TheatreSkipFinalizer.call(theatre:, user: current_user)
TheatrePostAdvancer.call(theatre:)
skipped = true
end
end
end
theatre.reload
return render json: theatre_info_json(theatre, skipped: false), status: :conflict if conflicted
render json: theatre_info_json(theatre, skipped:)
end
def unskip_vote
return head :unauthorized unless current_user
theatre = Theatre.find_by(id: params[:id])
return head :not_found unless theatre
requested_post_id = params[:post_id].to_i
return head :unprocessable_entity if requested_post_id <= 0
conflicted = false
theatre.with_lock do
if theatre.current_post
if theatre.current_post_id != requested_post_id
conflicted = true
else
TheatreSkipVote.where(theatre:, post_id: requested_post_id, user: current_user).delete_all
end
end
end
theatre.reload
return render json: theatre_info_json(theatre, skipped: false), status: :conflict if conflicted
render json: theatre_info_json(theatre, skipped: false)
end
def post_selection_weights
theatre = Theatre.find_by(id: params[:id])
return head :not_found unless theatre
render json: TheatrePostSelector.new(theatre:).weight_json
end
private
def theatre_info_json(theatre, host_flg: nil, post_id: nil, post_started_at: nil, skipped: nil)
host_flg = theatre.host_user_id == current_user&.id if host_flg.nil?
post_id = theatre.current_post_id if post_id.nil?
post_started_at = theatre.current_post_started_at if post_started_at.nil?
json = { host_flg:,
post_id:,
post_started_at:,
post_elapsed_ms: post_started_at ? ((Time.current - post_started_at) * 1000).floor : nil,
watching_users: theatre.watching_users.as_json(only: [:id, :name]),
skip_vote: skip_vote_status(theatre) }
json[:skipped] = skipped unless skipped.nil?
json
end
def skip_vote_status(theatre)
watching_user_ids = theatre.watching_users.ids
watching_users_count = watching_user_ids.size
required_count = (watching_users_count / 2) + 1
post = theatre.current_post
votes =
if post
TheatreSkipVote.where(theatre:, post:, user_id: watching_user_ids)
else
TheatreSkipVote.none
end
{ votes_count: post ? votes.count : 0,
required_count:,
watching_users_count:,
voted: post && current_user ? votes.exists?(user_id: current_user.id) : false }
end
end end
+7 -3
ファイルの表示
@@ -1,6 +1,9 @@
class UsersController < ApplicationController class UsersController < ApplicationController
def create def create
return head :unprocessable_entity if request.remote_ip.blank?
user = nil user = nil
User.transaction do User.transaction do
user = User.create!(inheritance_code: SecureRandom.uuid, role: :guest) user = User.create!(inheritance_code: SecureRandom.uuid, role: :guest)
attach_ip_address!(user) attach_ip_address!(user)
@@ -14,7 +17,8 @@ class UsersController < ApplicationController
def verify def verify
user = User.find_by(inheritance_code: params[:code]) user = User.find_by(inheritance_code: params[:code])
return render json: { valid: false } unless user return render json: { valid: false } unless user
return head :forbidden if user.banned?
return head :unprocessable_entity if request.remote_ip.blank?
attach_ip_address!(user) attach_ip_address!(user)
@@ -42,12 +46,12 @@ class UsersController < ApplicationController
return head :unauthorized if user&.id != params[:id].to_i return head :unauthorized if user&.id != params[:id].to_i
name = params[:name] name = params[:name]
return render_unprocessable_entity('名前は必須です.', field: :name) if name.blank? return head :bad_request if name.blank?
if user.update(name:) if user.update(name:)
render json: user.slice(:id, :name, :inheritance_code, :role), status: :ok render json: user.slice(:id, :name, :inheritance_code, :role), status: :ok
else else
render_validation_error user render json: user.errors, status: :unprocessable_entity
end end
end end
+6 -10
ファイルの表示
@@ -46,7 +46,7 @@ class WikiPagesController < ApplicationController
def diff def diff
id = params[:id] id = params[:id]
return render_bad_request('id は必須です.') if id.blank? return head :bad_request if id.blank?
from = params[:from].presence from = params[:from].presence
to = params[:to].presence to = params[:to].presence
@@ -56,7 +56,7 @@ class WikiPagesController < ApplicationController
from_rev = from && page.wiki_revisions.find(from) from_rev = from && page.wiki_revisions.find(from)
to_rev = to ? page.wiki_revisions.find(to) : page.current_revision to_rev = to ? page.wiki_revisions.find(to) : page.current_revision
if ((from_rev && !(from_rev.content?)) || !(to_rev&.content?)) if ((from_rev && !(from_rev.content?)) || !(to_rev&.content?))
return render_unprocessable_entity('差分を表示できない版です.') return head :unprocessable_entity
end end
diffs = Diff::LCS.sdiff(from_rev&.body&.lines || [], to_rev.body.lines) diffs = Diff::LCS.sdiff(from_rev&.body&.lines || [], to_rev.body.lines)
@@ -89,8 +89,7 @@ class WikiPagesController < ApplicationController
body = params[:body].to_s body = params[:body].to_s
message = params[:message].presence message = params[:message].presence
return render_unprocessable_entity('タイトルは必須です.', field: :title) if title.blank? return head :unprocessable_entity if title.blank? || 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_undiscard_or_create_by!(name: title)
@@ -102,10 +101,8 @@ class WikiPagesController < ApplicationController
message:) message:)
render json: WikiPageRepr.base(page), status: :created render json: WikiPageRepr.base(page), status: :created
rescue ActiveRecord::RecordInvalid => e rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotUnique
render_validation_error e.record head :unprocessable_entity
rescue ActiveRecord::RecordNotUnique
render_record_not_unique
end end
def update def update
@@ -115,8 +112,7 @@ class WikiPagesController < ApplicationController
title = params[:title]&.strip title = params[:title]&.strip
body = params[:body].to_s body = params[:body].to_s
return render_unprocessable_entity('タイトルは必須です.', field: :title) if title.blank? return head :unprocessable_entity if title.blank? || body.blank?
return render_unprocessable_entity('本文は必須です.', field: :body) if body.blank?
page = WikiPage.find(params[:id]) page = WikiPage.find(params[:id])
base_revision_id = params[:base_revision_id].presence base_revision_id = params[:base_revision_id].presence
+1 -4
ファイルの表示
@@ -1,10 +1,7 @@
class IpAddress < ApplicationRecord class IpAddress < ApplicationRecord
validates :ip_address, presence: true, length: { maximum: 16 } validates :ip_address, presence: true, length: { maximum: 16 }
validates :banned, inclusion: { in: [true, false] }
has_many :user_ips, dependent: :destroy has_many :user_ips, dependent: :destroy
has_many :users, through: :user_ips has_many :users, through: :user_ips
def banned? = banned_at.present?
def ban! = banned? || update!(banned_at: Time.current)
def unban! = update!(banned_at: nil)
end end
+4 -31
ファイルの表示
@@ -1,6 +1,7 @@
class Post < ApplicationRecord class Post < ApplicationRecord
require 'mini_magick' require 'mini_magick'
belongs_to :parent, class_name: 'Post', optional: true, foreign_key: 'parent_id'
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
@@ -12,24 +13,8 @@ class Post < ApplicationRecord
has_many :post_similarities, dependent: :delete_all has_many :post_similarities, dependent: :delete_all
has_many :post_versions has_many :post_versions
has_many :parent_post_implications,
class_name: 'PostImplication',
foreign_key: :post_id,
dependent: :destroy,
inverse_of: :post
has_many :parents, through: :parent_post_implications, source: :parent_post
has_many :child_post_implications,
class_name: 'PostImplication',
foreign_key: :parent_post_id,
dependent: :destroy,
inverse_of: :parent_post
has_many :children, through: :child_post_implications, source: :post
has_one_attached :thumbnail has_one_attached :thumbnail
attribute :version_no, :integer, default: 1
before_validation :normalise_url before_validation :normalise_url
validates :url, presence: true, uniqueness: true validates :url, presence: true, uniqueness: true
@@ -37,29 +22,17 @@ class Post < ApplicationRecord
validate :validate_original_created_range validate :validate_original_created_range
validate :url_must_be_http_url validate :url_must_be_http_url
def parent_posts = parents
def child_posts = children
def sibling_posts
parent_post_ids = parent_posts.order(:id).pluck(:id)
parent_post_ids.to_h { [_1, PostImplication.where(parent_post: _1).map(&:post)] }
end
def as_json options = { } def as_json options = { }
super(options).merge(thumbnail: thumbnail.attached? ? super(options).merge({ thumbnail: thumbnail.attached? ?
Rails.application.routes.url_helpers.rails_blob_url( Rails.application.routes.url_helpers.rails_blob_url(
thumbnail, only_path: false) : thumbnail, only_path: false) :
nil) nil })
rescue rescue
super(options).merge(thumbnail: nil) super(options).merge(thumbnail: nil)
end end
def snapshot_tag_names = tags.joins(:tag_name).order('tag_names.name').pluck('tag_names.name') def snapshot_tag_names = tags.joins(:tag_name).order('tag_names.name').pluck('tag_names.name')
def snapshot_parent_post_ids = parents.order(:id).pluck(:id)
def related limit: nil def related limit: nil
ids = post_similarities.order(cos: :desc) ids = post_similarities.order(cos: :desc)
ids = ids.limit(limit) if limit ids = ids.limit(limit) if limit
@@ -94,7 +67,7 @@ class Post < ApplicationRecord
return if !(f) || !(b) return if !(f) || !(b)
if f >= b if f >= b
errors.add :original_created_at, 'オリジナルの作成日時の順番がをかしぃです.' errors.add :original_created_before, 'オリジナルの作成日時の順番がをかしぃです.'
end end
end end
-19
ファイルの表示
@@ -1,19 +0,0 @@
class PostImplication < ApplicationRecord
self.primary_key = :post_id, :parent_post_id
belongs_to :post, inverse_of: :parent_post_implications
belongs_to :parent_post, class_name: 'Post', inverse_of: :child_post_implications
validates :post_id, presence: true, uniqueness: { scope: :parent_post_id }
validates :parent_post_id, presence: true
validate :parent_post_mustnt_be_itself
private
def parent_post_mustnt_be_itself
if parent_post_id == post_id
errors.add :parent_post_id, '親投稿に同じ投稿を設定することはできません.'
end
end
end
+1 -6
ファイルの表示
@@ -40,8 +40,6 @@ class Tag < ApplicationRecord
belongs_to :tag_name belongs_to :tag_name
delegate :wiki_page, to: :tag_name delegate :wiki_page, to: :tag_name
attribute :version_no, :integer, default: 1
delegate :name, to: :tag_name, allow_nil: true delegate :name, to: :tag_name, allow_nil: true
validates :tag_name, presence: true validates :tag_name, presence: true
@@ -81,16 +79,13 @@ class Tag < ApplicationRecord
def material_id = materials.first&.id def material_id = materials.first&.id
def has_deerjikists = deerjikists.loaded? ? deerjikists.any? : deerjikists.exists?
def self.tagme = find_or_create_by_tag_name!('タグ希望', category: :meta) def self.tagme = find_or_create_by_tag_name!('タグ希望', category: :meta)
def self.bot = find_or_create_by_tag_name!('bot操作', category: :meta) def self.bot = find_or_create_by_tag_name!('bot操作', category: :meta)
def self.no_deerjikist = find_or_create_by_tag_name!('ニジラー情報不詳', category: :meta) def self.no_deerjikist = find_or_create_by_tag_name!('ニジラー情報不詳', category: :meta)
def self.video = find_or_create_by_tag_name!('動画', category: :meta) def self.video = find_or_create_by_tag_name!('動画', category: :meta)
def self.niconico = find_or_create_by_tag_name!('ニコニコ', category: :meta) def self.niconico = find_or_create_by_tag_name!('ニコニコ', category: :meta)
def self.youtube = find_or_create_by_tag_name!('YouTube', category: :meta)
def self.normalise_tags! tag_names, with_tagme: true, def self.normalise_tags tag_names, with_tagme: true,
with_no_deerjikist: true, with_no_deerjikist: true,
deny_nico: true deny_nico: true
if deny_nico && tag_names.any? { |n| n.downcase.start_with?('nico:') } if deny_nico && tag_names.any? { |n| n.downcase.start_with?('nico:') }
-4
ファイルの表示
@@ -7,10 +7,6 @@ class Theatre < ApplicationRecord
class_name: 'TheatreWatchingUser', inverse_of: :theatre class_name: 'TheatreWatchingUser', inverse_of: :theatre
has_many :watching_users, through: :active_theatre_watching_users, source: :user has_many :watching_users, through: :active_theatre_watching_users, source: :user
has_many :programmes, class_name: 'TheatreProgramme'
has_many :skip_votes, class_name: 'TheatreSkipVote', dependent: :delete_all
has_many :skip_events, class_name: 'TheatreSkipEvent', dependent: :delete_all
belongs_to :host_user, class_name: 'User', optional: true belongs_to :host_user, class_name: 'User', optional: true
belongs_to :current_post, class_name: 'Post', optional: true belongs_to :current_post, class_name: 'Post', optional: true
belongs_to :created_by_user, class_name: 'User' belongs_to :created_by_user, class_name: 'User'
-6
ファイルの表示
@@ -1,6 +0,0 @@
class TheatreProgramme < ApplicationRecord
self.primary_key = :theatre_id, :position
belongs_to :theatre
belongs_to :post
end
-10
ファイルの表示
@@ -1,10 +0,0 @@
class TheatreSkipEvent < ApplicationRecord
belongs_to :theatre
belongs_to :post
belongs_to :skipped_by_user, class_name: 'User'
has_many :voters, class_name: 'TheatreSkipEventVoter', dependent: :delete_all
has_many :event_tags, class_name: 'TheatreSkipEventTag', dependent: :delete_all
has_many :users, through: :voters
has_many :tags, through: :event_tags
end
-6
ファイルの表示
@@ -1,6 +0,0 @@
class TheatreSkipEventTag < ApplicationRecord
self.primary_key = :theatre_skip_event_id, :tag_id
belongs_to :theatre_skip_event
belongs_to :tag
end
-6
ファイルの表示
@@ -1,6 +0,0 @@
class TheatreSkipEventVoter < ApplicationRecord
self.primary_key = :theatre_skip_event_id, :user_id
belongs_to :theatre_skip_event
belongs_to :user
end
-7
ファイルの表示
@@ -1,7 +0,0 @@
class TheatreSkipVote < ApplicationRecord
self.primary_key = :theatre_id, :post_id, :user_id
belongs_to :theatre
belongs_to :post
belongs_to :user
end
+1 -5
ファイルの表示
@@ -4,6 +4,7 @@ class User < ApplicationRecord
validates :name, length: { maximum: 255 } validates :name, length: { maximum: 255 }
validates :inheritance_code, presence: true, length: { maximum: 64 } validates :inheritance_code, presence: true, length: { maximum: 64 }
validates :role, presence: true, inclusion: { in: roles.keys } validates :role, presence: true, inclusion: { in: roles.keys }
validates :banned, inclusion: { in: [true, false] }
has_many :created_posts, has_many :created_posts,
class_name: 'Post', foreign_key: :uploaded_user_id, dependent: :nullify class_name: 'Post', foreign_key: :uploaded_user_id, dependent: :nullify
@@ -18,10 +19,5 @@ class User < ApplicationRecord
class_name: 'WikiPage', foreign_key: :updated_user_id, dependent: :nullify class_name: 'WikiPage', foreign_key: :updated_user_id, dependent: :nullify
def viewed?(post) = user_post_views.exists?(post_id: post.id) def viewed?(post) = user_post_views.exists?(post_id: post.id)
def gte_member? = member? || admin? def gte_member? = member? || admin?
def banned? = banned_at.present?
def ban! = banned? || update!(banned_at: Time.current)
def unban! = update!(banned_at: nil)
end end
-2
ファイルの表示
@@ -15,8 +15,6 @@ class WikiPage < ApplicationRecord
has_many :wiki_versions has_many :wiki_versions
attribute :version_no, :integer, default: 1
belongs_to :tag_name belongs_to :tag_name
validates :tag_name, presence: true validates :tag_name, presence: true
validates :body, presence: true validates :body, presence: true
+5 -51
ファイルの表示
@@ -2,65 +2,19 @@
module PostRepr module PostRepr
BASE_FIELDS = [ BASE = { include: { tags: TagRepr::BASE, uploaded_user: UserRepr::BASE } }.freeze
:id,
:version_no,
:url,
:title,
:thumbnail_base,
:original_created_from,
:original_created_before,
:created_at,
:updated_at
].freeze
module_function module_function
def base post, current_user = nil def base post, current_user = nil
json = common(post) json = post.as_json(BASE)
json['tags'] = tag_json(post.tags) return json.merge(viewed: false) unless current_user
json['uploaded_user'] = post.uploaded_user && UserRepr.base(post.uploaded_user)
json['viewed'] = current_user ? current_user.viewed?(post) : false
json
end
def detail post, current_user = nil, parent_posts: [], child_posts: [], viewed = current_user.viewed?(post)
sibling_posts: { }, related: [] json.merge(viewed:)
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
def card post
common(post).merge('parent_posts' => [], 'child_posts' => [])
end
def cards posts
posts.map { |post| card(post) }
end end
def many posts, current_user = nil def many posts, current_user = nil
posts.map { |p| base(p, current_user) } posts.map { |p| base(p, current_user) }
end end
def common post
BASE_FIELDS.to_h { |field| [field.to_s, post.public_send(field)] }
.merge('thumbnail' => thumbnail_url(post))
end
def tag_json tags
tags.map { |tag| TagRepr.inline(tag) }
end
def thumbnail_url post
return nil unless post.thumbnail.attached?
Rails.application.routes.url_helpers.rails_blob_url(post.thumbnail, only_path: false)
rescue
nil
end
end end
+1 -5
ファイルの表示
@@ -3,7 +3,7 @@
module TagRepr module TagRepr
BASE = { only: [:id, :category, :post_count, :created_at, :updated_at], BASE = { only: [:id, :category, :post_count, :created_at, :updated_at],
methods: [:name, :has_wiki, :material_id, :has_deerjikists] }.freeze methods: [:name, :has_wiki, :material_id] }.freeze
module_function module_function
@@ -12,9 +12,5 @@ module TagRepr
parents: tag.parents.map { _1.as_json(BASE) }) parents: tag.parents.map { _1.as_json(BASE) })
end end
def inline tag
tag.as_json(BASE).merge(aliases: [], parents: [])
end
def many(tags) = tags.map { |t| base(t) } def many(tags) = tags.map { |t| base(t) }
end end
+1 -1
ファイルの表示
@@ -24,7 +24,7 @@ class PostVersionRecorder < VersionRecorder
url: @record.url, url: @record.url,
thumbnail_base: @record.thumbnail_base, thumbnail_base: @record.thumbnail_base,
tags: @record.snapshot_tag_names.join(' '), tags: @record.snapshot_tag_names.join(' '),
parent_post_ids: @record.snapshot_parent_post_ids.join(' '), parent_id: @record.parent_id,
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 }
end end
-29
ファイルの表示
@@ -1,29 +0,0 @@
class TheatrePostAdvancer
def self.call(theatre:)
new(theatre:).call
end
def initialize(theatre:)
@theatre = theatre
end
def call
previous_post = theatre.current_post
post = TheatrePostSelector.new(theatre:).select
TheatreSkipVote.where(theatre:, post: previous_post).delete_all if previous_post
theatre.update!(current_post: post, current_post_started_at: post ? Time.current : nil)
if post
position = (theatre.programmes.maximum(:position) || 0) + 1
theatre.programmes.create!(position:, post:)
end
post
end
private
attr_reader :theatre
end
-119
ファイルの表示
@@ -1,119 +0,0 @@
class TheatrePostSelector
Candidate = Struct.new(:post, :weight, :penalty, :tags, keyword_init: true)
ELIGIBLE_POST_URL_CONDITION =
["url LIKE '%nicovideo.jp%'",
"url LIKE '%youtube.com/watch%'",
"url LIKE '%youtu.be/%'"]
.join(' OR ')
def initialize theatre:
@theatre = theatre
end
def select
candidates = weighted_candidates
return nil if candidates.empty?
total = candidates.sum(&:weight)
target = rand * total
candidates.each do |candidate|
target -= candidate.weight
return candidate.post if target <= 0
end
candidates.last.post
end
def weight_json limit: 20
candidates = weighted_candidates
sorted = candidates.sort_by { |candidate| [candidate.weight, candidate.post.id] }
{ tag_penalties: tag_penalty_json,
lightest_posts: post_weight_json(sorted.first(limit)),
heaviest_posts: post_weight_json(sorted.reverse.first(limit)) }
end
private
attr_reader :theatre
def weighted_candidates
@weighted_candidates ||= begin
penalties = tag_penalties
posts = eligible_posts.includes(tags: :tag_name).to_a
posts.map do |post|
post_tags = post.tags.to_a
penalty = post_tags.sum { |tag| penalties[tag.id].to_i }
Candidate.new(
post:,
penalty:,
tags: post_tags,
weight: 1.0 / (1.0 + penalty))
end
end
end
def eligible_posts
posts = Post.where(ELIGIBLE_POST_URL_CONDITION)
posts = posts.where.not(id: theatre.current_post_id) if theatre.current_post_id
posts
end
def active_user_ids
@active_user_ids ||= theatre.watching_users.ids
end
def tag_penalties
@tag_penalties ||=
if active_user_ids.empty?
{}
else
TheatreSkipEventVoter
.joins(theatre_skip_event: :event_tags)
.where(user_id: active_user_ids)
.group('theatre_skip_event_tags.tag_id')
.count
end
end
def tag_penalty_json
return [] if tag_penalties.empty?
tags = Tag.where(id: tag_penalties.keys).includes(:tag_name).index_by(&:id)
tag_penalties
.map { |tag_id, penalty|
tag = tags[tag_id]
next unless tag
{ tag: light_tag_json(tag),
penalty: }
}
.compact
.sort_by { |row| [-row[:penalty], row[:tag][:name].to_s] }
end
def post_weight_json candidates
candidates.map { |candidate|
{ post: light_post_json(candidate.post),
weight: candidate.weight,
penalty: candidate.penalty,
tags: candidate.tags.map { |tag| light_tag_json(tag) } }
}
end
def light_post_json post
{ id: post.id,
title: post.title,
url: post.url }
end
def light_tag_json tag
{ id: tag.id,
name: tag.name,
category: tag.category }
end
end
-40
ファイルの表示
@@ -1,40 +0,0 @@
class TheatreSkipFinalizer
def self.call(theatre:, user:)
new(theatre:, user:).call
end
def initialize(theatre:, user:)
@theatre = theatre
@user = user
end
def call
return unless theatre.current_post
post = theatre.current_post
voters = TheatreSkipVote.where(theatre:, post:).includes(:user).map(&:user)
return if voters.empty?
event = TheatreSkipEvent.create!(
theatre:,
post:,
skipped_by_user: user,
programme_position: theatre.programmes.maximum(:position))
voters.uniq(&:id).each do |voter|
TheatreSkipEventVoter.create!(theatre_skip_event: event, user: voter)
end
post.tags.find_each do |tag|
TheatreSkipEventTag.create!(theatre_skip_event: event, tag:)
end
TheatreSkipVote.where(theatre:, post:).delete_all
event
end
private
attr_reader :theatre, :user
end
+10 -35
ファイルの表示
@@ -16,20 +16,19 @@ class VersionRecorder
@record = record_class.unscoped.lock.find(@record.id) @record = record_class.unscoped.lock.find(@record.id)
latest = latest_version latest = latest_version
validate_version_sequence!(latest) if !(latest) && @event_type != 'create'
raise "#{ version_class.name } first event must be create"
end
if @event_type == 'create' && latest
raise "#{ version_class.name } create event already exists"
end
attrs = snapshot_attributes attrs = snapshot_attributes
if @event_type == 'update' && latest && same_snapshot?(latest, attrs) return latest if @event_type == 'update' && latest && same_snapshot?(latest, attrs)
return latest
end
version = version_class.create!( version_class.create!(base_attributes(latest).merge(record_key => @record).merge(attrs))
base_attributes(latest).merge(record_key => @record).merge(attrs))
update_record_version_no!(version.version_no)
version
end end
end end
@@ -46,31 +45,7 @@ class VersionRecorder
created_by_user: @created_by_user } created_by_user: @created_by_user }
end end
def update_record_version_no! version_no def same_snapshot?(version, attrs) = attrs.all? { |k, v| version.public_send(k) == v }
@record.update_columns(version_no:)
@record.version_no = version_no
end
def validate_version_sequence! latest
if !(latest) && @event_type != 'create'
raise "#{ version_class.name } first event must be create"
end
if @event_type == 'create' && latest
raise "#{ version_class.name } create event already exists"
end
return unless latest
if @record.version_no != latest.version_no
raise ("#{ record_class.name }##{ @record.id } version_no is #{ @record.version_no }, " +
"but latest #{ version_class.name } version_no is #{ latest.version_no }")
end
end
def same_snapshot? version, attrs
attrs.all? { |k, v| version.public_send(k) == v }
end
def validate_event_type! def validate_event_type!
return if EVENT_TYPES.include?(@event_type) return if EVENT_TYPES.include?(@event_type)
-73
ファイルの表示
@@ -1,73 +0,0 @@
require 'json'
require 'net/http'
require 'uri'
module Youtube
class ApiClient
ENDPOINT = 'https://www.googleapis.com/youtube/v3'
def initialize api_key: ENV.fetch('YOUTUBE_API_KEY')
@api_key = api_key
end
def search_videos q:, published_after: nil, published_before: nil, page_token: nil
get_json('/search', {
part: 'snippet',
type: 'video',
q:,
order: 'date',
maxResults: 50,
regionCode: 'JP',
relevanceLanguage: 'ja',
publishedAfter: published_after&.iso8601,
publishedBefore: published_before&.iso8601,
pageToken: page_token }.compact)
end
def videos ids
return { 'items' => [] } if ids.empty?
get_json('/videos', part: 'snippet,status,contentDetails', id: ids.join(','))
end
def playlist_items playlist_id:, page_token: nil
get_json('/playlistItems', {
part: 'snippet,contentDetails,status',
playlistId: playlist_id,
maxResults: 50,
pageToken: page_token }.compact)
end
def channel id: nil, handle: nil
raise ArgumentError, 'id or handle is required' if id.present? == handle.present?
params = { part: 'snippet,contentDetails' }
params[:id] = id if id.present?
params[:forHandle] = handle if handle.present?
get_json('/channels', params)
end
private
def get_json path, params
uri = URI(ENDPOINT + path)
uri.query = URI.encode_www_form(params.merge(key: @api_key))
response = Net::HTTP.start(uri.host,
uri.port,
use_ssl: true,
open_timeout: 10,
read_timeout: 30) do |http|
http.get(uri)
end
unless response.is_a?(Net::HTTPSuccess)
raise "YouTube API error: #{ response.code } #{ response.body }"
end
JSON.parse(response.body)
end
end
end
-168
ファイルの表示
@@ -1,168 +0,0 @@
require 'open-uri'
require 'set'
require 'time'
module Youtube
class Sync
def initialize client: ApiClient.new
@client = client
end
def sync!
video_ids = discover_video_ids
return if video_ids.empty?
video_ids.each_slice(50) do |ids|
@client.videos(ids).fetch('items', []).each do |item|
sync_video!(VideoItem.new(item))
end
end
end
private
def discover_video_ids
ids = Set.new
query_terms.each do |q|
response = @client.search_videos(q:, published_after: sync_since)
response.fetch('items', []).each do |item|
video_id = item.dig('id', 'videoId')
ids << video_id if video_id.present?
end
end
playlist_ids.each do |playlist_id|
each_playlist_item(playlist_id) do |item|
video_id = item.dig('contentDetails', 'videoId')
video_id ||= item.dig('snippet', 'resourceId', 'videoId')
ids << video_id if video_id.present?
end
end
ids.to_a
end
def sync_video! video
post = Post.where('url REGEXP ?', youtube_url_regexp(video.id)).first
original_created_from = video.published_at.change(sec: 0)
original_created_before = original_created_from + 1.minute
post_created = false
post_changed = false
if post
post.assign_attributes(title: video.title,
original_created_from:,
original_created_before:,
thumbnail_base: video.thumbnail_url)
post_changed = post.changed?
post.save! if post_changed
attach_thumbnail_if_needed!(post, video.thumbnail_url)
else
post_created = true
post = Post.create!(
title: video.title,
url: video.url,
thumbnail_base: video.thumbnail_url,
uploaded_user_id: nil,
original_created_from:,
original_created_before:)
attach_thumbnail_if_needed!(post, video.thumbnail_url)
sync_post_tags!(post, [Tag.tagme.id, Tag.bot.id, Tag.youtube.id, Tag.video.id])
end
kept_tag_ids = post.tags.pluck(:id).to_set
desired_tag_ids = kept_tag_ids.to_a
deerjikist = Deerjikist.find_by(platform: :youtube, code: video.channel_id)
if deerjikist
desired_tag_ids.delete(Tag.no_deerjikist.id)
desired_tag_ids << deerjikist.tag_id
elsif post.tags.where(category: :deerjikist).none?
desired_tag_ids << Tag.no_deerjikist.id
end
desired_tag_ids.uniq!
sync_post_tags!(post, desired_tag_ids, current_tag_ids: kept_tag_ids)
if post_created
PostVersionRecorder.record!(post:, event_type: :create, created_by_user: nil)
elsif post_changed || kept_tag_ids != desired_tag_ids.to_set
PostVersionRecorder.ensure_snapshot!(post, created_by_user: nil)
PostVersionRecorder.record!(post:, event_type: :update, created_by_user: nil)
end
end
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
desired_tag_ids = desired_tag_ids.compact.to_set
to_add = desired_tag_ids - current_tag_ids
to_remove = current_tag_ids - desired_tag_ids
Tag.where(id: to_add.to_a).find_each do |tag|
begin
PostTag.create!(post:, tag:)
rescue ActiveRecord::RecordNotUnique
;
end
end
PostTag.where(post_id: post.id, tag_id: to_remove.to_a).kept.find_each do |pt|
pt.discard_by!(nil)
end
end
def attach_thumbnail_if_needed! post, thumbnail_url
return if post.thumbnail.attached?
return if thumbnail_url.blank?
post.thumbnail.attach(
io: URI.open(thumbnail_url),
filename: File.basename(URI.parse(thumbnail_url).path),
content_type: 'image/jpeg')
post.resized_thumbnail!
end
def youtube_url_regexp id
escaped = Regexp.escape(id)
"(youtube\\.com/watch\\?v=#{ escaped }|youtu\\.be/#{ escaped })([^A-Za-z0-9_-]|$)"
end
def query_terms = ['ぼざろクリーチャーシリーズ', '伊地知ニジカ', '伊地知虹鹿']
def playlist_ids
['PLrOch4zHkI5vu29b-f9umUQQ4tQkuWLPX',
'PLrOch4zHkI5vOK0RaytQq6PbucxQkkL0K',
'PLrOch4zHkI5tdwm9vSegiDQJOM-hgpcOC']
end
def sync_since = 14.days.ago
def each_playlist_item playlist_id
page_token = nil
loop do
response = @client.playlist_items(playlist_id:, page_token:)
response.fetch('items', []).each do |item|
yield item
end
page_token = response['nextPageToken']
break if page_token.blank?
end
end
end
end
-32
ファイルの表示
@@ -1,32 +0,0 @@
require 'time'
module Youtube
class VideoItem
attr_reader :id, :title, :channel_id, :published_at, :thumbnail_url, :raw_tags
def initialize item
snippet = item.fetch('snippet')
@id = item.fetch('id')
@title = snippet['title']
@channel_id = snippet['channelId']
@published_at = Time.iso8601(snippet['publishedAt'])
@thumbnail_url = pick_thumbnail(snippet['thumbnails'] || { })
@raw_tags = snippet['tags'] || []
end
def url = "https://www.youtube.com/watch?v=#{ @id }"
private
def pick_thumbnail thumbnails
['maxres', 'standard', 'high', 'medium', 'default'].each do |key|
url = thumbnails.dig(key, 'url')
return url if url.present?
end
nil
end
end
end
+1 -7
ファイルの表示
@@ -24,7 +24,6 @@ Rails.application.routes.draw do
patch '', action: :update patch '', action: :update
get :deerjikists get :deerjikists
put :deerjikists, action: :update_deerjikists
end end
end end
@@ -85,14 +84,9 @@ Rails.application.routes.draw do
member do member do
put :watching put :watching
patch :next_post patch :next_post
put :skip_vote
delete :skip_vote, action: :unskip_vote
get :post_selection_weights
end end
resources :comments, controller: :theatre_comments, only: [:index, :create, :destroy] resources :comments, controller: :theatre_comments, only: [:index, :create]
resources :programmes, controller: :theatre_programmes, only: [:index]
resources :skip_events, controller: :theatre_skip_events, only: [:index]
end end
resources :materials, only: [:index, :show, :create, :update, :destroy] resources :materials, only: [:index, :show, :create, :update, :destroy]
-8
ファイルの表示
@@ -17,11 +17,3 @@ every 1.day, at: '0:00 am' do
rake 'post_similarity:calc', environment: 'production' rake 'post_similarity:calc', environment: 'production'
rake 'tag_similarity:calc', environment: 'production' rake 'tag_similarity:calc', environment: 'production'
end end
every 1.day, at: '7:50 am' do
rake 'nico:export', environment: 'production'
end
every :hour do
rake 'post:sync', environment: 'production'
end
-24
ファイルの表示
@@ -1,24 +0,0 @@
class CreatePostImplications < ActiveRecord::Migration[8.0]
def up
create_table :post_implications, primary_key: [:post_id, :parent_post_id] do |t|
t.references :post, null: false, foreign_key: true, index: false
t.references :parent_post, null: false, foreign_key: { to_table: :posts }
t.timestamps
t.check_constraint 'post_id <> parent_post_id',
name: 'chk_post_implications_no_self'
end
add_column :post_versions, :parent_post_ids, :text, null: false, after: :parent_id
remove_column :post_versions, :parent_id, :bigint
remove_reference :posts, :parent, foreign_key: { to_table: :posts }
end
def down
add_reference :posts, :parent, foreign_key: { to_table: :posts }, after: :thumbnail_base
add_column :post_versions, :parent_id, :bigint, after: :post_id
remove_column :post_versions, :parent_post_ids, :text
drop_table :post_implications
end
end
@@ -1,16 +0,0 @@
class RenameBannedToBannedAtInUsersAndIpAddresses < ActiveRecord::Migration[8.0]
def up
[:users, :ip_addresses].each do
add_column _1, :banned_at, :datetime, after: :banned
add_index _1, :banned_at
remove_column _1, :banned
end
end
def down
[:ip_addresses, :users].each do
add_column _1, :banned, :boolean, null: false, default: false, after: :banned_at
remove_column _1, :banned_at
end
end
end
-27
ファイルの表示
@@ -1,27 +0,0 @@
class AddVersionNoToPosts < ActiveRecord::Migration[8.0]
def up
add_column :posts, :version_no, :integer
execute <<~SQL
UPDATE
posts
SET
version_no = (
SELECT
MAX(version_no)
FROM
post_versions
WHERE
post_id = posts.id)
SQL
change_column_null :posts, :version_no, false
add_check_constraint :posts, 'version_no > 0', name: 'chk_posts_version_no_positive'
end
def down
remove_check_constraint :posts, name: 'chk_posts_version_no_positive'
remove_column :posts, :version_no
end
end
-37
ファイルの表示
@@ -1,37 +0,0 @@
class AddVersionNoToTags < ActiveRecord::Migration[8.0]
def up
add_column :tags, :version_no, :integer
execute <<~SQL
UPDATE
tags
SET
version_no = (
CASE category
WHEN 'nico' THEN
(SELECT
MAX(version_no)
FROM
nico_tag_versions
WHERE
tag_id = tags.id)
ELSE
(SELECT
MAX(version_no)
FROM
tag_versions
WHERE
tag_id = tags.id)
END)
SQL
change_column_null :tags, :version_no, false
add_check_constraint :tags, 'version_no > 0', name: 'chk_tags_version_no_positive'
end
def down
remove_check_constraint :tags, name: 'chk_tags_version_no_positive'
remove_column :tags, :version_no
end
end
-27
ファイルの表示
@@ -1,27 +0,0 @@
class AddVersionNoToWikiPages < ActiveRecord::Migration[8.0]
def up
add_column :wiki_pages, :version_no, :integer
execute <<~SQL
UPDATE
wiki_pages
SET
version_no = (
SELECT
MAX(version_no)
FROM
wiki_versions
WHERE
wiki_page_id = wiki_pages.id)
SQL
change_column_null :wiki_pages, :version_no, false
add_check_constraint :wiki_pages, 'version_no > 0', name: 'chk_wiki_pages_version_no_positive'
end
def down
remove_check_constraint :wiki_pages, name: 'chk_wiki_pages_version_no_positive'
remove_column :wiki_pages, :version_no
end
end
-10
ファイルの表示
@@ -1,10 +0,0 @@
class CreateTheatreProgrammes < ActiveRecord::Migration[8.0]
def change
create_table :theatre_programmes, primary_key: [:theatre_id, :position] do |t|
t.references :theatre, null: false, foreign_key: true
t.integer :position, null: false
t.references :post, null: false, foreign_key: true
t.datetime :created_at, null: false
end
end
end
-36
ファイルの表示
@@ -1,36 +0,0 @@
class CreateTheatreSkipVotesAndEvents < ActiveRecord::Migration[8.0]
def change
create_table :theatre_skip_votes, primary_key: [:theatre_id, :post_id, :user_id] do |t|
t.references :theatre, null: false, foreign_key: true
t.references :post, null: false, foreign_key: true
t.references :user, null: false, foreign_key: true
t.timestamps
end
create_table :theatre_skip_events do |t|
t.references :theatre, null: false, foreign_key: true
t.references :post, null: false, foreign_key: true
t.references :skipped_by_user, null: false, foreign_key: { to_table: :users }
t.integer :programme_position
t.datetime :created_at, null: false
end
create_table :theatre_skip_event_voters, primary_key: [:theatre_skip_event_id, :user_id] do |t|
t.references :theatre_skip_event, null: false, foreign_key: true
t.references :user, null: false, foreign_key: true
end
create_table :theatre_skip_event_tags, primary_key: [:theatre_skip_event_id, :tag_id] do |t|
t.references :theatre_skip_event, null: false, foreign_key: true
t.references :tag, null: false, foreign_key: true
end
add_index :theatre_skip_events, [:theatre_id, :created_at]
add_index :theatre_skip_votes, [:theatre_id, :post_id, :created_at],
name: 'idx_theatre_skip_votes_theatre_post_created'
add_index :theatre_skip_event_voters, [:user_id, :theatre_skip_event_id],
name: 'idx_theatre_skip_event_voters_user_event'
add_index :theatre_skip_event_tags, [:tag_id, :theatre_skip_event_id],
name: 'idx_theatre_skip_event_tags_tag_event'
end
end
生成ファイル
+9 -84
ファイルの表示
@@ -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_06_06_000000) do ActiveRecord::Schema[8.0].define(version: 2026_04_26_120600) 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
@@ -50,10 +50,9 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_06_000000) do
create_table "ip_addresses", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| create_table "ip_addresses", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.binary "ip_address", limit: 16, null: false t.binary "ip_address", limit: 16, null: false
t.datetime "banned_at" t.boolean "banned", default: false, null: false
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.index ["banned_at"], name: "index_ip_addresses_on_banned_at"
t.index ["ip_address"], name: "index_ip_addresses_on_ip_address", unique: true t.index ["ip_address"], name: "index_ip_addresses_on_ip_address", unique: true
end end
@@ -120,15 +119,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_06_000000) do
t.check_constraint "`version_no` > 0", name: "nico_tag_versions_version_no_positive" t.check_constraint "`version_no` > 0", name: "nico_tag_versions_version_no_positive"
end end
create_table "post_implications", primary_key: ["post_id", "parent_post_id"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "post_id", null: false
t.bigint "parent_post_id", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["parent_post_id"], name: "index_post_implications_on_parent_post_id"
t.check_constraint "`post_id` <> `parent_post_id`", name: "chk_post_implications_no_self"
end
create_table "post_similarities", primary_key: ["post_id", "target_post_id"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| create_table "post_similarities", primary_key: ["post_id", "target_post_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 "target_post_id", null: false t.bigint "target_post_id", null: false
@@ -165,12 +155,13 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_06_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.text "parent_post_ids", null: false t.bigint "parent_id"
t.datetime "original_created_from" t.datetime "original_created_from"
t.datetime "original_created_before" t.datetime "original_created_before"
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.bigint "created_by_user_id" t.bigint "created_by_user_id"
t.index ["created_by_user_id"], name: "index_post_versions_on_created_by_user_id" t.index ["created_by_user_id"], name: "index_post_versions_on_created_by_user_id"
t.index ["parent_id"], name: "index_post_versions_on_parent_id"
t.index ["post_id", "version_no"], name: "index_post_versions_on_post_id_and_version_no", unique: true t.index ["post_id", "version_no"], name: "index_post_versions_on_post_id_and_version_no", unique: true
t.index ["post_id"], name: "index_post_versions_on_post_id" t.index ["post_id"], name: "index_post_versions_on_post_id"
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"
@@ -181,15 +172,15 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_06_000000) do
t.string "title" t.string "title"
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.bigint "parent_id"
t.bigint "uploaded_user_id" t.bigint "uploaded_user_id"
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "original_created_from" t.datetime "original_created_from"
t.datetime "original_created_before" t.datetime "original_created_before"
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.integer "version_no", null: false t.index ["parent_id"], name: "index_posts_on_parent_id"
t.index ["uploaded_user_id"], name: "index_posts_on_uploaded_user_id" t.index ["uploaded_user_id"], name: "index_posts_on_uploaded_user_id"
t.index ["url"], name: "index_posts_on_url", unique: true t.index ["url"], name: "index_posts_on_url", unique: true
t.check_constraint "`version_no` > 0", name: "chk_posts_version_no_positive"
end end
create_table "settings", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| create_table "settings", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
@@ -264,10 +255,8 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_06_000000) do
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.datetime "discarded_at"
t.integer "version_no", null: false
t.index ["discarded_at"], name: "index_tags_on_discarded_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 "`version_no` > 0", name: "chk_tags_version_no_positive"
end end
create_table "theatre_comments", primary_key: ["theatre_id", "no"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| create_table "theatre_comments", primary_key: ["theatre_id", "no"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
@@ -283,55 +272,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_06_000000) do
t.index ["user_id"], name: "index_theatre_comments_on_user_id" t.index ["user_id"], name: "index_theatre_comments_on_user_id"
end end
create_table "theatre_programmes", primary_key: ["theatre_id", "position"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "theatre_id", null: false
t.integer "position", null: false
t.bigint "post_id", null: false
t.datetime "created_at", null: false
t.index ["post_id"], name: "index_theatre_programmes_on_post_id"
t.index ["theatre_id"], name: "index_theatre_programmes_on_theatre_id"
end
create_table "theatre_skip_event_tags", primary_key: ["theatre_skip_event_id", "tag_id"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "theatre_skip_event_id", null: false
t.bigint "tag_id", null: false
t.index ["tag_id", "theatre_skip_event_id"], name: "idx_theatre_skip_event_tags_tag_event"
t.index ["tag_id"], name: "index_theatre_skip_event_tags_on_tag_id"
t.index ["theatre_skip_event_id"], name: "index_theatre_skip_event_tags_on_theatre_skip_event_id"
end
create_table "theatre_skip_event_voters", primary_key: ["theatre_skip_event_id", "user_id"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "theatre_skip_event_id", null: false
t.bigint "user_id", null: false
t.index ["theatre_skip_event_id"], name: "index_theatre_skip_event_voters_on_theatre_skip_event_id"
t.index ["user_id", "theatre_skip_event_id"], name: "idx_theatre_skip_event_voters_user_event"
t.index ["user_id"], name: "index_theatre_skip_event_voters_on_user_id"
end
create_table "theatre_skip_events", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "theatre_id", null: false
t.bigint "post_id", null: false
t.bigint "skipped_by_user_id", null: false
t.integer "programme_position"
t.datetime "created_at", null: false
t.index ["post_id"], name: "index_theatre_skip_events_on_post_id"
t.index ["skipped_by_user_id"], name: "index_theatre_skip_events_on_skipped_by_user_id"
t.index ["theatre_id", "created_at"], name: "index_theatre_skip_events_on_theatre_id_and_created_at"
t.index ["theatre_id"], name: "index_theatre_skip_events_on_theatre_id"
end
create_table "theatre_skip_votes", primary_key: ["theatre_id", "post_id", "user_id"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "theatre_id", null: false
t.bigint "post_id", null: false
t.bigint "user_id", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["post_id"], name: "index_theatre_skip_votes_on_post_id"
t.index ["theatre_id", "post_id", "created_at"], name: "idx_theatre_skip_votes_theatre_post_created"
t.index ["theatre_id"], name: "index_theatre_skip_votes_on_theatre_id"
t.index ["user_id"], name: "index_theatre_skip_votes_on_user_id"
end
create_table "theatre_watching_users", primary_key: ["theatre_id", "user_id"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| create_table "theatre_watching_users", primary_key: ["theatre_id", "user_id"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "theatre_id", null: false t.bigint "theatre_id", null: false
t.bigint "user_id", null: false t.bigint "user_id", null: false
@@ -386,10 +326,9 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_06_000000) do
t.string "name" t.string "name"
t.string "inheritance_code", limit: 64, null: false t.string "inheritance_code", limit: 64, null: false
t.string "role", null: false t.string "role", null: false
t.datetime "banned_at" t.boolean "banned", default: false, null: false
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.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| create_table "wiki_assets", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
@@ -422,12 +361,10 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_06_000000) do
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.datetime "discarded_at" t.datetime "discarded_at"
t.integer "next_asset_no", default: 1, null: false t.integer "next_asset_no", default: 1, null: false
t.integer "version_no", null: false
t.index ["created_user_id"], name: "index_wiki_pages_on_created_user_id" t.index ["created_user_id"], name: "index_wiki_pages_on_created_user_id"
t.index ["discarded_at"], name: "index_wiki_pages_on_discarded_at" t.index ["discarded_at"], name: "index_wiki_pages_on_discarded_at"
t.index ["tag_name_id"], name: "index_wiki_pages_on_tag_name_id", unique: true t.index ["tag_name_id"], name: "index_wiki_pages_on_tag_name_id", unique: true
t.index ["updated_user_id"], name: "index_wiki_pages_on_updated_user_id" t.index ["updated_user_id"], name: "index_wiki_pages_on_updated_user_id"
t.check_constraint "`version_no` > 0", name: "chk_wiki_pages_version_no_positive"
end end
create_table "wiki_revision_lines", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| create_table "wiki_revision_lines", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
@@ -491,8 +428,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_06_000000) do
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", "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", 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_tags", "posts" add_foreign_key "post_tags", "posts"
@@ -500,7 +435,9 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_06_000000) do
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_tags", "users", column: "deleted_user_id"
add_foreign_key "post_versions", "posts" add_foreign_key "post_versions", "posts"
add_foreign_key "post_versions", "posts", column: "parent_id"
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", "posts", column: "parent_id"
add_foreign_key "posts", "users", column: "uploaded_user_id" add_foreign_key "posts", "users", column: "uploaded_user_id"
add_foreign_key "settings", "users" add_foreign_key "settings", "users"
add_foreign_key "tag_implications", "tags" add_foreign_key "tag_implications", "tags"
@@ -513,18 +450,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_06_000000) do
add_foreign_key "tags", "tag_names" add_foreign_key "tags", "tag_names"
add_foreign_key "theatre_comments", "theatres" add_foreign_key "theatre_comments", "theatres"
add_foreign_key "theatre_comments", "users" add_foreign_key "theatre_comments", "users"
add_foreign_key "theatre_programmes", "posts"
add_foreign_key "theatre_programmes", "theatres"
add_foreign_key "theatre_skip_event_tags", "tags"
add_foreign_key "theatre_skip_event_tags", "theatre_skip_events"
add_foreign_key "theatre_skip_event_voters", "theatre_skip_events"
add_foreign_key "theatre_skip_event_voters", "users"
add_foreign_key "theatre_skip_events", "posts"
add_foreign_key "theatre_skip_events", "theatres"
add_foreign_key "theatre_skip_events", "users", column: "skipped_by_user_id"
add_foreign_key "theatre_skip_votes", "posts"
add_foreign_key "theatre_skip_votes", "theatres"
add_foreign_key "theatre_skip_votes", "users"
add_foreign_key "theatre_watching_users", "theatres" add_foreign_key "theatre_watching_users", "theatres"
add_foreign_key "theatre_watching_users", "users" add_foreign_key "theatre_watching_users", "users"
add_foreign_key "theatres", "posts", column: "current_post_id" add_foreign_key "theatres", "posts", column: "current_post_id"
-6
ファイルの表示
@@ -1,6 +0,0 @@
namespace :post do
desc '投稿同期(ニコニコ以外)'
task sync: :environment do
Youtube::Sync.new.sync!
end
end
-10
ファイルの表示
@@ -1,10 +0,0 @@
FactoryBot.define do
factory :ip_address do
ip_address { IPAddr.new('203.0.113.10').hton }
banned_at { nil }
trait :banned do
banned_at { Time.current }
end
end
end
+3 -12
ファイルの表示
@@ -1,24 +1,15 @@
FactoryBot.define do FactoryBot.define do
factory :user do factory :user do
name { nil } name { "test-user" }
inheritance_code { SecureRandom.uuid } inheritance_code { SecureRandom.uuid }
role { 'guest' } role { "guest" }
banned_at { nil }
trait :guest do
role { 'guest' }
end
trait :member do trait :member do
role { 'member' } role { "member" }
end end
trait :admin do trait :admin do
role { 'admin' } role { 'admin' }
end end
trait :banned do
banned_at { Time.current }
end
end end
end end
-51
ファイルの表示
@@ -1,51 +0,0 @@
require 'rails_helper'
RSpec.describe PostImplication, type: :model do
let!(:post_record) do
Post.create!(
title: 'post',
url: 'https://example.com/post-implication-post'
)
end
let!(:parent_post) do
Post.create!(
title: 'parent post',
url: 'https://example.com/post-implication-parent'
)
end
it 'is valid with post and parent_post' do
implication = described_class.new(
post: post_record,
parent_post:
)
expect(implication).to be_valid
end
it 'does not allow same post as parent_post' do
implication = described_class.new(
post: post_record,
parent_post: post_record
)
expect(implication).not_to be_valid
expect(implication.errors[:parent_post_id]).to be_present
end
it 'does not allow duplicate pair' do
described_class.create!(
post: post_record,
parent_post:
)
duplicate = described_class.new(
post: post_record,
parent_post:
)
expect(duplicate).not_to be_valid
expect(duplicate.errors[:post_id]).to be_present
end
end
+1 -1
ファイルの表示
@@ -19,7 +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(' '),
parent_post_ids: post_record.snapshot_parent_post_ids.join(' '), parent: post_record.parent,
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,
created_at: Time.current, created_at: Time.current,
+1 -1
ファイルの表示
@@ -161,7 +161,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),
parent_post_ids: post.snapshot_parent_post_ids.join(' '), parent: post.parent,
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,
created_at: Time.current, created_at: Time.current,
-47
ファイルの表示
@@ -1,47 +0,0 @@
require 'rails_helper'
RSpec.describe 'error responses', type: :request do
describe 'manual input errors' do
it 'returns a stable payload for bad requests' do
get '/tags/name/%20/deerjikists'
expect(response).to have_http_status(:bad_request)
expect(json).to include(
'type' => 'bad_request',
'message' => be_present,
'errors' => {},
'base_errors' => [be_present])
end
it 'returns a stable field-error payload for unprocessable requests' do
member = create(:user, :member)
tag = create(:tag, :general, name: 'error_response_tag')
sign_in_as(member)
patch "/tags/#{ tag.id }", params: { category: 'nico' }
expect(response).to have_http_status(:unprocessable_entity)
expect(json).to include(
'type' => 'validation_error',
'message' => '入力内容を確認してください.',
'base_errors' => [])
expect(json.fetch('errors')).to include(
'category' => ['ニコタグは変更できません.'])
end
end
describe 'model validation errors' do
it 'returns field messages for model errors' do
user = create(:user)
sign_in_as(user)
put "/users/#{ user.id }", params: { name: 'a' * 256 }
expect(response).to have_http_status(:unprocessable_entity)
expect(json).to include(
'type' => 'validation_error',
'message' => '入力内容を確認してください.')
expect(json.fetch('errors').fetch('name')).to include(be_present)
end
end
end
+8 -18
ファイルの表示
@@ -141,21 +141,16 @@ RSpec.describe 'Materials API', type: :request do
context 'when logged in' do context 'when logged in' do
before { sign_in_as(guest_user) } before { sign_in_as(guest_user) }
it 'returns 422 when tag is blank' do it 'returns 400 when tag is blank' do
post '/materials', params: { tag: ' ', file: dummy_upload } post '/materials', params: { tag: ' ', file: dummy_upload }
expect(response).to have_http_status(:unprocessable_entity) expect(response).to have_http_status(:bad_request)
expect(json.fetch('errors')).to include(
'tag' => ['タグは必須です.'])
end end
it 'returns 422 when both file and url are blank' do it 'returns 400 when both file and url are blank' do
post '/materials', params: { tag: 'material_create_blank' } post '/materials', params: { tag: 'material_create_blank' }
expect(response).to have_http_status(:unprocessable_entity) expect(response).to have_http_status(:bad_request)
expect(json.fetch('errors')).to include(
'file' => ['ファイルまたは URL は必須です.'],
'url' => ['ファイルまたは URL は必須です.'])
end end
it 'creates a material with an attached file' do it 'creates a material with an attached file' do
@@ -266,26 +261,21 @@ RSpec.describe 'Materials API', type: :request do
expect(response).to have_http_status(:not_found) expect(response).to have_http_status(:not_found)
end end
it 'returns 422 when tag is blank' do it 'returns 400 when tag is blank' do
put "/materials/#{ material.id }", params: { put "/materials/#{ material.id }", params: {
tag: ' ', tag: ' ',
file: dummy_upload file: dummy_upload
} }
expect(response).to have_http_status(:unprocessable_entity) expect(response).to have_http_status(:bad_request)
expect(json.fetch('errors')).to include(
'tag' => ['タグは必須です.'])
end end
it 'returns 422 when both file and url are blank' do it 'returns 400 when both file and url are blank' do
put "/materials/#{ material.id }", params: { put "/materials/#{ material.id }", params: {
tag: 'material_update_no_payload' tag: 'material_update_no_payload'
} }
expect(response).to have_http_status(:unprocessable_entity) expect(response).to have_http_status(:bad_request)
expect(json.fetch('errors')).to include(
'file' => ['ファイルまたは URL は必須です.'],
'url' => ['ファイルまたは URL は必須です.'])
end end
it 'updates tag, url, file, and updated_by_user' do it 'updates tag, url, file, and updated_by_user' do
+7 -93
ファイルの表示
@@ -3,68 +3,12 @@ require 'rails_helper'
RSpec.describe 'NicoTags', type: :request do RSpec.describe 'NicoTags', type: :request do
describe 'GET /tags/nico' do describe 'GET /tags/nico' do
it 'returns paginated tags and total count' do it 'returns tags and next_cursor when overflowing limit' do
create_list(:tag, 3, :nico) create_list(:tag, 21, :nico)
get '/tags/nico', params: { limit: 20 }
get '/tags/nico', params: { page: 2, limit: 2 }
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
expect(json['tags'].size).to eq(1) expect(json['tags'].size).to eq(20)
expect(json['count']).to eq(3) expect(json['next_cursor']).to be_present
end
it 'filters by nico tag name, linked tag name, and link status' do
linked = create(:tag, :nico)
linked.tag_name.update!(name: 'nico:search_linked')
unlinked = create(:tag, :nico)
unlinked.tag_name.update!(name: 'nico:search_unlinked')
other = create(:tag, :nico)
other.tag_name.update!(name: 'nico:other')
destination = create(:tag, :general)
destination.tag_name.update!(name: 'destination_search')
NicoTagRelation.create!(nico_tag: linked, tag: destination)
NicoTagRelation.create!(nico_tag: other, tag: create(:tag, :general))
get '/tags/nico', params: {
name: 'search_',
linked_tag: 'destination_',
link_status: 'linked'
}
expect(response).to have_http_status(:ok)
expect(json['count']).to eq(1)
expect(json.fetch('tags').map { |tag| tag['id'] }).to eq([linked.id])
get '/tags/nico', params: { name: 'search_', link_status: 'unlinked' }
expect(json['count']).to eq(1)
expect(json.fetch('tags').map { |tag| tag['id'] }).to eq([unlinked.id])
end
it 'sorts by name and timestamps' do
older = create(:tag, :nico)
older.tag_name.update!(name: 'nico:a')
older.update_columns(created_at: 2.days.ago)
newer = create(:tag, :nico)
newer.tag_name.update!(name: 'nico:b')
newer.update_columns(created_at: 1.day.ago)
older_post_tag =
PostTag.create!(post: Post.create!(url: 'https://example.com/nico-older'), tag: older)
older_post_tag.update_columns(created_at: 1.hour.ago)
newer_post_tag =
PostTag.create!(post: Post.create!(url: 'https://example.com/nico-newer'), tag: newer)
newer_post_tag.update_columns(created_at: 2.hours.ago)
get '/tags/nico', params: { order: 'name:desc' }
expect(json.fetch('tags').map { |tag| tag['id'] }).to eq([newer.id, older.id])
get '/tags/nico', params: { order: 'created_at:asc' }
expect(json.fetch('tags').map { |tag| tag['id'] }).to eq([older.id, newer.id])
get '/tags/nico', params: { order: 'updated_at:desc' }
expect(json.fetch('tags').map { |tag| tag['id'] }).to eq([older.id, newer.id])
expect(Time.zone.parse(json.fetch('tags').first.fetch('recent_post_tag_created_at')))
.to be_within(1.second).of(older_post_tag.created_at)
end end
end end
@@ -131,7 +75,7 @@ RSpec.describe 'NicoTags', type: :request do
expect(versions.last.created_by_user_id).to eq(admin.id) expect(versions.last.created_by_user_id).to eq(admin.id)
end end
it 'returns 422 when linked tag normalises to nico tag' do it '400 when linked tag normalises to nico tag' do
sign_in_as(member) sign_in_as(member)
other_nico = create(:tag, :nico, name: 'nico:linked_ng') other_nico = create(:tag, :nico, name: 'nico:linked_ng')
@@ -143,37 +87,7 @@ RSpec.describe 'NicoTags', type: :request do
patch "/tags/nico/#{nico_tag.id}", params: { tags: 'linked_ng_alias' } patch "/tags/nico/#{nico_tag.id}", params: { tags: 'linked_ng_alias' }
}.not_to change(NicoTagVersion, :count) }.not_to change(NicoTagVersion, :count)
expect(response).to have_http_status(:unprocessable_entity) expect(response).to have_http_status(:bad_request)
expect(json.fetch('errors')).to include(
'tags' => ['ニコニコ・タグ同士は連携できません.'])
end
it 'returns the tags field error when a nico tag is specified directly' do
sign_in_as(member)
patch "/tags/nico/#{nico_tag.id}", params: { tags: 'nico:linked_ng' }
expect(response).to have_http_status(:unprocessable_entity)
expect(json.fetch('errors')).to include(
'tags' => ['ニコニコ・タグ同士は連携できません.'])
end
it 'returns tag name validation errors on the tags field and rolls back created tags' do
sign_in_as(member)
TagNameSanitisationRule.create!(
priority: 1,
source_pattern: 'invalid',
replacement: 'valid'
)
nico_tag
expect {
patch "/tags/nico/#{nico_tag.id}", params: { tags: 'created_first invalid' }
}.not_to change(TagName, :count)
expect(response).to have_http_status(:unprocessable_entity)
expect(json.fetch('errors').fetch('tags')).to include(
a_string_including('タグ名 “invalid”:', '名前に使用できない文字が含まれてゐます.'))
end end
end end
end end
ファイル差分が大きすぎるため省略します 差分を読込み
+2 -7
ファイルの表示
@@ -26,7 +26,6 @@ RSpec.describe 'TagVersions API', type: :request do
created_by_user:, created_by_user:,
created_at: created_at:
) )
version =
TagVersion.create!( TagVersion.create!(
tag: tag, tag: tag,
version_no: version_no, version_no: version_no,
@@ -36,12 +35,8 @@ RSpec.describe 'TagVersions API', type: :request do
aliases: Array(aliases).join(' '), aliases: Array(aliases).join(' '),
parent_tag_ids: Array(parent_tags).map(&:id).join(' '), parent_tag_ids: Array(parent_tags).map(&:id).join(' '),
created_by_user: created_by_user, created_by_user: created_by_user,
created_at: created_at) created_at: created_at
)
tag.update_columns(version_no: version_no) if tag.has_attribute?(:version_no)
tag.version_no = version_no if tag.respond_to?(:version_no=)
version
end end
let!(:v1) do let!(:v1) do
+12 -246
ファイルの表示
@@ -8,35 +8,23 @@ RSpec.describe 'Tags deerjikists API', type: :request do
let!(:tag) { create(:tag, category: :deerjikist) } let!(:tag) { create(:tag, category: :deerjikist) }
let(:member) { create(:user, :member) }
let(:guest) { create(:user, role: :guest) }
before do before do
# show_by_name / deerjikists_by_name 用に名前を固定
tag.tag_name.update!(name: 'deerjika') tag.tag_name.update!(name: 'deerjika')
end end
describe 'GET /tags/:id/deerjikists' do describe 'GET /tags/:id/deerjikists' do
subject(:do_request) do subject(:do_request) do
get "/tags/#{tag_id}/deerjikists" get "/tags/#{ tag_id }/deerjikists"
end end
let(:tag_id) { tag.id } let(:tag_id) { tag.id }
context 'when tag exists and has no deerjikists' do context 'when tag exists and has no deerjikists' do
it 'returns 200 with tag and empty deerjikists array' do it 'returns 200 and empty array' do
do_request do_request
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
expect(json).to eq([])
expect(json).to be_a(Hash)
expect(json['tag']).to be_a(Hash)
expect(json['tag']['id']).to eq(tag.id)
expect(json['tag']['name']).to eq('deerjika')
expect(json['tag']['category']).to eq('deerjikist')
expect(json['tag']['has_deerjikists']).to eq(false)
expect(json['deerjikists']).to eq([])
end end
end end
@@ -46,24 +34,14 @@ RSpec.describe 'Tags deerjikists API', type: :request do
Deerjikist.create!(platform: platform2, code: code2, tag: tag) Deerjikist.create!(platform: platform2, code: code2, tag: tag)
end end
it 'returns 200 with tag and deerjikists array' do it 'returns 200 and deerjikists array' do
do_request do_request
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
expect(json).to be_a(Hash) expect(json).to be_a(Array)
expect(json.size).to eq(2)
expect(json['tag']).to be_a(Hash) expect(json.map { |h| [h['platform'], h['code']] }).to contain_exactly(
expect(json['tag']['id']).to eq(tag.id)
expect(json['tag']['name']).to eq('deerjika')
expect(json['tag']['category']).to eq('deerjikist')
expect(json['tag']['has_deerjikists']).to eq(true)
expect(json['deerjikists']).to be_a(Array)
expect(json['deerjikists'].size).to eq(2)
expect(json['deerjikists'].map { |h| [h['platform'], h['code']] })
.to contain_exactly(
[platform1, code1], [platform1, code1],
[platform2, code2], [platform2, code2],
) )
@@ -75,7 +53,6 @@ RSpec.describe 'Tags deerjikists API', type: :request do
it 'returns 404' do it 'returns 404' do
do_request do_request
expect(response).to have_http_status(:not_found) expect(response).to have_http_status(:not_found)
end end
end end
@@ -83,7 +60,7 @@ RSpec.describe 'Tags deerjikists API', type: :request do
describe 'GET /tags/name/:name/deerjikists' do describe 'GET /tags/name/:name/deerjikists' do
subject(:do_request) do subject(:do_request) do
get "/tags/name/#{name}/deerjikists" get "/tags/name/#{ name }/deerjikists"
end end
let(:name) { 'deerjika' } let(:name) { 'deerjika' }
@@ -93,7 +70,6 @@ RSpec.describe 'Tags deerjikists API', type: :request do
it 'returns 400' do it 'returns 400' do
do_request do_request
expect(response).to have_http_status(:bad_request) expect(response).to have_http_status(:bad_request)
end end
end end
@@ -103,233 +79,23 @@ RSpec.describe 'Tags deerjikists API', type: :request do
it 'returns 404' do it 'returns 404' do
do_request do_request
expect(response).to have_http_status(:not_found) expect(response).to have_http_status(:not_found)
end end
end end
context 'when tag exists and has no deerjikists' do
it 'returns 200 with tag and empty deerjikists array' do
do_request
expect(response).to have_http_status(:ok)
expect(json).to be_a(Hash)
expect(json['tag']).to be_a(Hash)
expect(json['tag']['id']).to eq(tag.id)
expect(json['tag']['name']).to eq('deerjika')
expect(json['tag']['category']).to eq('deerjikist')
expect(json['tag']['has_deerjikists']).to eq(false)
expect(json['deerjikists']).to eq([])
end
end
context 'when tag exists and has deerjikists' do context 'when tag exists and has deerjikists' do
before do before do
Deerjikist.create!(platform: platform1, code: code1, tag: tag) Deerjikist.create!(platform: platform1, code: code1, tag: tag)
end end
it 'returns 200 with tag and deerjikists array' do it 'returns 200 and deerjikists array' do
do_request do_request
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
expect(json).to be_a(Hash)
expect(json['tag']).to be_a(Hash)
expect(json['tag']['id']).to eq(tag.id)
expect(json['tag']['name']).to eq('deerjika')
expect(json['tag']['category']).to eq('deerjikist')
expect(json['tag']['has_deerjikists']).to eq(true)
expect(json['deerjikists']).to be_a(Array)
expect(json['deerjikists'].size).to eq(1)
expect(json['deerjikists'][0]['platform']).to eq(platform1)
expect(json['deerjikists'][0]['code']).to eq(code1)
end
end
end
describe 'PUT /tags/:id/deerjikists' do
subject(:do_request) do
put "/tags/#{tag_id}/deerjikists", params: payload, as: :json
end
let(:tag_id) { tag.id }
let(:payload) do
[
{ platform: platform1, code: code1 },
{ platform: platform2, code: code2 },
]
end
context 'when not logged in' do
it 'returns 401' do
do_request
expect(response).to have_http_status(:unauthorized)
end
end
context 'when logged in but not member' do
before do
sign_in_as guest
end
it 'returns 403' do
do_request
expect(response).to have_http_status(:forbidden)
end
end
context 'when tag does not exist' do
let(:tag_id) { 9_999_999 }
before do
sign_in_as member
end
it 'returns 404' do
do_request
expect(response).to have_http_status(:not_found)
end
end
context 'when logged in as member' do
before do
sign_in_as member
end
context 'when tag has no deerjikists' do
it 'creates deerjikists and returns deerjikists array' do
expect {
do_request
}.to change { Deerjikist.where(tag: tag).count }.from(0).to(2)
expect(response).to have_http_status(:ok)
expect(json).to be_a(Array)
expect(json.map { |h| [h['platform'], h['code']] })
.to contain_exactly(
[platform1, code1],
[platform2, code2],
)
expect(Deerjikist.where(tag: tag).map { |d| [d.platform, d.code] })
.to contain_exactly(
[platform1, code1],
[platform2, code2],
)
end
end
context 'when tag already has deerjikists' do
before do
Deerjikist.create!(platform: platform1, code: 'old-code-1', tag: tag)
Deerjikist.create!(platform: platform2, code: 'old-code-2', tag: tag)
end
it 'replaces deerjikists and returns deerjikists array' do
do_request
expect(response).to have_http_status(:ok)
expect(Deerjikist.where(tag: tag).map { |d| [d.platform, d.code] })
.to contain_exactly(
[platform1, code1],
[platform2, code2],
)
expect(Deerjikist.exists?(platform: platform1, code: 'old-code-1')).to eq(false)
expect(Deerjikist.exists?(platform: platform2, code: 'old-code-2')).to eq(false)
expect(json).to be_a(Array)
expect(json.map { |h| [h['platform'], h['code']] })
.to contain_exactly(
[platform1, code1],
[platform2, code2],
)
end
end
context 'when payload is empty array' do
let(:payload) { [] }
before do
Deerjikist.create!(platform: platform1, code: code1, tag: tag)
Deerjikist.create!(platform: platform2, code: code2, tag: tag)
end
it 'clears deerjikists and returns empty array' do
expect {
do_request
}.to change { Deerjikist.where(tag: tag).count }.from(2).to(0)
expect(response).to have_http_status(:ok)
expect(json).to eq([])
end
end
context 'when a row is invalid' do
let(:payload) do
[
{ platform: '', code: code1 },
]
end
it 'returns 422 with indexed field errors and does not replace existing deerjikists' 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).to include(
'type' => 'validation_error',
'message' => '入力内容を確認してください.',
'base_errors' => [])
expect(json.fetch('errors')).to include(
'deerjikists.0.platform' => [be_present])
end
end
context 'when youtube code is handle' do
let(:channel_id) { 'UCabcdefghijklmnopqrstuv' }
let(:payload) do
[
{ platform: 'youtube', code: '@deerjika' },
]
end
before do
allow(Net::HTTP).to receive(:get).and_return(
%(<link rel="canonical" href="https://www.youtube.com/channel/#{channel_id}">),
)
end
it 'normalises youtube handle to channel id' do
expect {
do_request
}.to change { Deerjikist.where(tag: tag).count }.from(0).to(1)
expect(response).to have_http_status(:ok)
expect(Net::HTTP).to have_received(:get)
expect(Deerjikist.exists?(platform: 'youtube', code: channel_id, tag: tag))
.to eq(true)
expect(json).to be_a(Array) expect(json).to be_a(Array)
expect(json.size).to eq(1) expect(json.size).to eq(1)
expect(json[0]['platform']).to eq('youtube') expect(json[0]['platform']).to eq(platform1)
expect(json[0]['code']).to eq(channel_id) expect(json[0]['code']).to eq(code1)
end
end end
end end
end end
+2
ファイルの表示
@@ -964,6 +964,8 @@ RSpec.describe 'Tags API', type: :request do
end end
it '別名を他 tag から奪った場合、奪はれた側の tag version も作成する' do it '別名を他 tag から奪った場合、奪はれた側の tag version も作成する' do
pending '#329 で対応予定'
old_owner = Tag.create!( old_owner = Tag.create!(
tag_name: TagName.create!(name: 'put_alias_old_owner'), tag_name: TagName.create!(name: 'put_alias_old_owner'),
category: :general category: :general
-60
ファイルの表示
@@ -80,26 +80,6 @@ RSpec.describe 'TheatreComments', type: :request do
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
expect(response.parsed_body.map { |row| row['no'] }).to eq([3, 2, 1]) expect(response.parsed_body.map { |row| row['no'] }).to eq([3, 2, 1])
end end
it '削除済みコメントは deleted として返し、本文を隠す' do
comment_2.discard!
get "/theatres/#{theatre.id}/comments", params: { no_gt: 1 }
expect(response).to have_http_status(:ok)
deleted_comment = response.parsed_body.find { _1['no'] == 2 }
expect(deleted_comment).to include(
'deleted' => true,
'content' => nil
)
visible_comment = response.parsed_body.find { _1['no'] == 3 }
expect(visible_comment).to include(
'deleted' => false,
'content' => 'third comment'
)
end
end end
describe 'POST /theatres/:theatre_id/comments' do describe 'POST /theatres/:theatre_id/comments' do
@@ -167,44 +147,4 @@ RSpec.describe 'TheatreComments', type: :request do
}) })
end end
end end
describe 'DELETE /theatres/:theatre_id/comments/:id' do
let(:theatre) { create(:theatre) }
let(:alice) { create(:user, name: 'Alice') }
let(:bob) { create(:user, name: 'Bob') }
let!(:comment) do
create(
:theatre_comment,
theatre: theatre,
no: 1,
user: alice,
content: 'delete target'
)
end
it 'returns 401 when not logged in' do
delete "/theatres/#{theatre.id}/comments/#{comment.no}"
expect(response).to have_http_status(:unauthorized)
expect(comment.reload.discarded?).to eq(false)
end
it 'allows the comment owner to delete it' do
sign_in_as(alice)
delete "/theatres/#{theatre.id}/comments/#{comment.no}"
expect(response).to have_http_status(:no_content)
expect(comment.reload.discarded?).to eq(true)
end
it 'returns 403 when another user tries to delete it' do
sign_in_as(bob)
delete "/theatres/#{theatre.id}/comments/#{comment.no}"
expect(response).to have_http_status(:forbidden)
expect(comment.reload.discarded?).to eq(false)
end
end
end end
-38
ファイルの表示
@@ -1,38 +0,0 @@
require 'rails_helper'
RSpec.describe 'TheatreProgrammes', type: :request do
describe 'GET /theatres/:theatre_id/programmes' do
let(:theatre) { create(:theatre) }
let(:other_theatre) { create(:theatre) }
let(:post_1) { Post.create!(title: 'first', url: 'https://www.nicovideo.jp/watch/sm1') }
let(:post_2) { Post.create!(title: 'second', url: 'https://www.nicovideo.jp/watch/sm2') }
let(:other_post) { Post.create!(title: 'other', url: 'https://www.nicovideo.jp/watch/sm3') }
before do
TheatreProgramme.create!(theatre:, position: 1, post: post_1, created_at: 2.minutes.ago)
TheatreProgramme.create!(theatre:, position: 2, post: post_2, created_at: 1.minute.ago)
TheatreProgramme.create!(
theatre: other_theatre,
position: 1,
post: other_post,
created_at: 1.minute.ago
)
end
it 'returns programmes for the theatre in descending position with post json' do
get "/theatres/#{theatre.id}/programmes"
expect(response).to have_http_status(:ok)
expect(json.map { _1['position'] }).to eq([2, 1])
expect(json.map { _1.dig('post', 'title') }).to eq(['second', 'first'])
expect(json.first['post']).to include('id' => post_2.id, 'url' => post_2.url)
end
it 'filters programmes by position_gt' do
get "/theatres/#{theatre.id}/programmes", params: { position_gt: 1 }
expect(response).to have_http_status(:ok)
expect(json.map { _1['position'] }).to eq([2])
end
end
end
+8 -226
ファイルの表示
@@ -14,24 +14,10 @@ RSpec.describe 'Theatres API', type: :request do
let(:member) { create(:user, :member, name: 'member user') } let(:member) { create(:user, :member, name: 'member user') }
let(:other_user) { create(:user, :member, name: 'other user') } let(:other_user) { create(:user, :member, name: 'other user') }
let!(:niconico_post) do
Post.create!(
title: 'niconico post',
url: 'https://www.nicovideo.jp/watch/sm123'
)
end
let!(:second_niconico_post) do
Post.create!(
title: 'second niconico post',
url: 'https://www.nicovideo.jp/watch/sm456'
)
end
let!(:youtube_post) do let!(:youtube_post) do
Post.create!( Post.create!(
title: 'youtube post', title: 'youtube post',
url: 'https://www.youtube.com/watch?v=yt123' url: 'https://www.youtube.com/watch?v=spec123'
) )
end end
@@ -134,8 +120,7 @@ RSpec.describe 'Theatres API', type: :request do
expect(json).to include( expect(json).to include(
'host_flg' => true, 'host_flg' => true,
'post_id' => nil, 'post_id' => nil,
'post_started_at' => nil, 'post_started_at' => nil
'post_elapsed_ms' => nil
) )
expect(json.fetch('watching_users')).to contain_exactly( expect(json.fetch('watching_users')).to contain_exactly(
@@ -192,8 +177,7 @@ RSpec.describe 'Theatres API', type: :request do
expect(json).to include( expect(json).to include(
'host_flg' => false, 'host_flg' => false,
'post_id' => nil, 'post_id' => nil,
'post_started_at' => nil, 'post_started_at' => nil
'post_elapsed_ms' => nil
) )
expect(json.fetch('watching_users')).to contain_exactly( expect(json.fetch('watching_users')).to contain_exactly(
@@ -220,7 +204,7 @@ RSpec.describe 'Theatres API', type: :request do
) )
theatre.update!( theatre.update!(
host_user: other_user, host_user: other_user,
current_post: niconico_post, current_post: youtube_post,
current_post_started_at: started_at current_post_started_at: started_at
) )
sign_in_as(member) sign_in_as(member)
@@ -236,11 +220,9 @@ RSpec.describe 'Theatres API', type: :request do
expect(theatre.host_user_id).to eq(member.id) expect(theatre.host_user_id).to eq(member.id)
expect(json['host_flg']).to eq(true) expect(json['host_flg']).to eq(true)
expect(json['post_id']).to eq(niconico_post.id) expect(json['post_id']).to eq(youtube_post.id)
expect(Time.zone.parse(json['post_started_at'])) expect(Time.zone.parse(json['post_started_at']))
.to be_within(1.second).of(started_at) .to be_within(1.second).of(started_at)
expect(json['post_elapsed_ms'])
.to be_within(1_000).of(120_000)
end end
end end
end end
@@ -291,36 +273,16 @@ RSpec.describe 'Theatres API', type: :request do
it 'sets current_post to an eligible post and updates current_post_started_at' do it 'sets current_post to an eligible post and updates current_post_started_at' do
expect { do_request } expect { do_request }
.to change { theatre.reload.current_post_id } .to change { theatre.reload.current_post_id }
.from(nil).to(youtube_post.id)
expect(response).to have_http_status(:no_content) expect(response).to have_http_status(:no_content)
expect([niconico_post.id, second_niconico_post.id, youtube_post.id])
.to include(theatre.reload.current_post_id)
expect(theatre.reload.current_post_started_at) expect(theatre.reload.current_post_started_at)
.to be_within(1.second).of(Time.current) .to be_within(1.second).of(Time.current)
expect(theatre.programmes.count).to eq(1)
end
end
context 'when only a YouTube post is eligible' do
before do
niconico_post.destroy!
second_niconico_post.destroy!
theatre.update!(host_user: member)
sign_in_as(member)
end
it 'sets current_post to the YouTube post' do
do_request
expect(response).to have_http_status(:no_content)
expect(theatre.reload.current_post_id).to eq(youtube_post.id)
end end
end end
context 'when current user is host and no eligible post exists' do context 'when current user is host and no eligible post exists' do
before do before do
niconico_post.destroy!
second_niconico_post.destroy!
youtube_post.destroy! youtube_post.destroy!
theatre.update!( theatre.update!(
host_user: member, host_user: member,
@@ -337,189 +299,9 @@ RSpec.describe 'Theatres API', type: :request do
theatre.reload theatre.reload
expect(theatre.current_post_id).to be_nil expect(theatre.current_post_id).to be_nil
expect(theatre.current_post_started_at).to be_nil expect(theatre.current_post_started_at)
.to be_within(1.second).of(Time.current)
end end
end end
end end
describe 'PUT /theatres/:id/skip_vote' do
subject(:do_request) do
put "/theatres/#{theatre.id}/skip_vote", params: { post_id: requested_post_id }
end
let(:third_user) { create(:user, :member, name: 'third user') }
let(:requested_post_id) { niconico_post.id }
before do
theatre.update!(current_post: niconico_post, current_post_started_at: 10.seconds.ago)
[member, other_user, third_user].each do |user|
TheatreWatchingUser.create!(
theatre:,
user:,
expires_at: 10.seconds.from_now
)
end
end
it 'returns 401 when not logged in' do
sign_out
expect { do_request }.not_to change(TheatreSkipVote, :count)
expect(response).to have_http_status(:unauthorized)
end
it 'returns 422 when post_id is invalid' do
sign_in_as(member)
expect {
put "/theatres/#{theatre.id}/skip_vote", params: { post_id: 'invalid' }
}.not_to change(TheatreSkipVote, :count)
expect(response).to have_http_status(:unprocessable_entity)
end
it 'records a vote and returns the current vote status before majority' do
sign_in_as(member)
expect { do_request }.to change(TheatreSkipVote, :count).by(1)
expect(response).to have_http_status(:ok)
expect(json['skipped']).to eq(false)
expect(json['post_id']).to eq(niconico_post.id)
expect(json['skip_vote']).to include(
'votes_count' => 1,
'required_count' => 2,
'watching_users_count' => 3,
'voted' => true
)
end
it 'finalizes skip when votes reach majority and stores voters and tag snapshots' do
tag = create(:tag, name: 'skip-target')
PostTag.create!(post: niconico_post, tag:)
TheatreSkipVote.create!(theatre:, post: niconico_post, user: member)
sign_in_as(other_user)
expect { do_request }
.to change(TheatreSkipEvent, :count).by(1)
.and change(TheatreSkipEventVoter, :count).by(2)
.and change(TheatreSkipEventTag, :count).by(1)
expect(response).to have_http_status(:ok)
expect(json['skipped']).to eq(true)
expect([second_niconico_post.id, youtube_post.id]).to include(json['post_id'])
event = TheatreSkipEvent.last
expect(event.post).to eq(niconico_post)
expect(event.users).to contain_exactly(member, other_user)
expect(event.tags).to contain_exactly(tag)
expect(TheatreSkipVote.where(theatre:, post: niconico_post)).to be_empty
end
it 'does not record a vote when requested post is no longer current' do
theatre.update!(current_post: second_niconico_post)
sign_in_as(member)
expect { do_request }.not_to change(TheatreSkipVote, :count)
expect(response).to have_http_status(:conflict)
expect(json['post_id']).to eq(second_niconico_post.id)
expect(json['skip_vote']).to include(
'votes_count' => 0,
'voted' => false
)
end
end
describe 'DELETE /theatres/:id/skip_vote' do
let(:requested_post_id) { niconico_post.id }
before do
theatre.update!(current_post: niconico_post, current_post_started_at: 10.seconds.ago)
TheatreWatchingUser.create!(theatre:, user: member, expires_at: 10.seconds.from_now)
TheatreSkipVote.create!(theatre:, post: niconico_post, user: member)
sign_in_as(member)
end
it 'removes the current user vote' do
expect {
delete "/theatres/#{theatre.id}/skip_vote", params: { post_id: requested_post_id }
}.to change(TheatreSkipVote, :count).by(-1)
expect(response).to have_http_status(:ok)
expect(json['skip_vote']).to include(
'votes_count' => 0,
'required_count' => 1,
'watching_users_count' => 1,
'voted' => false
)
end
it 'does not remove a vote when requested post is no longer current' do
theatre.update!(current_post: second_niconico_post)
expect {
delete "/theatres/#{theatre.id}/skip_vote", params: { post_id: requested_post_id }
}.not_to change(TheatreSkipVote, :count)
expect(response).to have_http_status(:conflict)
expect(json['post_id']).to eq(second_niconico_post.id)
end
end
describe 'GET /theatres/:id/skip_events' do
before do
sign_in_as(member)
end
it 'does not expose skip voters' do
event = TheatreSkipEvent.create!(
theatre:,
post: niconico_post,
skipped_by_user: member,
created_at: Time.current
)
TheatreSkipEventVoter.create!(theatre_skip_event: event, user: member)
get "/theatres/#{theatre.id}/skip_events"
expect(response).to have_http_status(:ok)
expect(json.first).to include(
'id' => event.id,
'theatre_id' => theatre.id
)
expect(json.first).not_to have_key('voters')
expect(json.first).not_to have_key('skipped_by_user')
end
end
describe 'GET /theatres/:id/post_selection_weights' do
before do
theatre.update!(current_post: niconico_post)
TheatreWatchingUser.create!(theatre:, user: member, expires_at: 10.seconds.from_now)
sign_in_as(member)
end
it 'returns tag penalties and candidate weights for the current watchers' do
tag = create(:tag, name: 'heavy-tag')
PostTag.create!(post: second_niconico_post, tag:)
event = TheatreSkipEvent.create!(
theatre:,
post: niconico_post,
skipped_by_user: member,
created_at: Time.current
)
TheatreSkipEventVoter.create!(theatre_skip_event: event, user: member)
TheatreSkipEventTag.create!(theatre_skip_event: event, tag:)
get "/theatres/#{theatre.id}/post_selection_weights"
expect(response).to have_http_status(:ok)
expect(json['tag_penalties'].first['penalty']).to eq(1)
expect(json['lightest_posts'].first['post']['id']).to eq(second_niconico_post.id)
expect(json['lightest_posts'].first['penalty']).to eq(1)
end
end
end end
+71 -230
ファイルの表示
@@ -1,268 +1,109 @@
require 'rails_helper' require "rails_helper"
RSpec.describe 'Users', type: :request do
let(:remote_ip) { '203.0.113.10' }
before do
allow_any_instance_of(ActionDispatch::Request)
.to receive(:remote_ip)
.and_return(remote_ip)
end
def auth_headers(user)
{ 'X-Transfer-Code' => user.inheritance_code }
end
describe 'POST /users' do
it 'creates guest user, IpAddress and UserIp, and returns code' do
expect {
post '/users'
}.to change(User, :count).by(1)
.and change(IpAddress, :count).by(1)
.and change(UserIp, :count).by(1)
RSpec.describe "Users", type: :request do
describe "POST /users" do
it "creates guest user and returns code" do
post "/users"
expect(response).to have_http_status(:created) expect(response).to have_http_status(:created)
expect(json['code']).to be_present expect(json["code"]).to be_present
expect(json['user']['role']).to eq('guest') expect(json["user"]["role"]).to eq("guest")
user = User.last
ip_address = IpAddress.find_by(ip_address: IPAddr.new(remote_ip).hton)
expect(user.role).to eq('guest')
expect(ip_address).to be_present
expect(UserIp.exists?(user:, ip_address:)).to eq(true)
end
it 'returns 403 and does not create user when current IP address is banned' do
IpAddress.create!(
ip_address: IPAddr.new(remote_ip).hton,
banned_at: Time.current
)
expect {
post '/users'
}.not_to change(User, :count)
expect(response).to have_http_status(:forbidden)
expect(UserIp.count).to eq(0)
end end
end end
describe 'POST /users/code/renew' do describe "POST /users/code/renew" do
it 'returns 401 when not logged in' do it "returns 401 when not logged in" do
post '/users/code/renew' sign_out
post "/users/code/renew"
expect(response).to have_http_status(:unauthorized)
end
end
describe "PUT /users/:id" do
let(:user) { create(:user, name: "old-name", role: "guest") }
it "returns 401 when current_user id mismatch" do
sign_in_as(create(:user))
put "/users/#{user.id}", params: { name: "new-name" }
expect(response).to have_http_status(:unauthorized) expect(response).to have_http_status(:unauthorized)
end end
it 'returns 403 when current user is banned' do it "returns 400 when name is blank" do
user = create(:user, :banned) sign_in_as(user)
put "/users/#{user.id}", params: { name: " " }
post '/users/code/renew', headers: auth_headers(user) expect(response).to have_http_status(:bad_request)
expect(response).to have_http_status(:forbidden)
end end
it 'returns 403 when current IP address is banned' do it "updates name and returns 201 with user slice" do
user = create(:user) sign_in_as(user)
put "/users/#{user.id}", params: { name: "new-name" }
IpAddress.create!(
ip_address: IPAddr.new(remote_ip).hton,
banned_at: Time.current
)
post '/users/code/renew', headers: auth_headers(user)
expect(response).to have_http_status(:forbidden)
end
end
describe 'PUT /users/:id' do
let(:user) { create(:user, name: 'old-name', role: 'guest') }
it 'returns 401 when current_user id mismatch' do
other_user = create(:user)
put "/users/#{user.id}",
params: { name: 'new-name' },
headers: auth_headers(other_user)
expect(response).to have_http_status(:unauthorized)
end
it 'returns 422 when name is blank' do
put "/users/#{user.id}",
params: { name: ' ' },
headers: auth_headers(user)
expect(response).to have_http_status(:unprocessable_entity)
expect(json.fetch('errors')).to include(
'name' => ['名前は必須です.'])
end
it 'updates name and returns user slice' do
put "/users/#{user.id}",
params: { name: 'new-name' },
headers: auth_headers(user)
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
expect(json['id']).to eq(user.id) expect(json["id"]).to eq(user.id)
expect(json['name']).to eq('new-name') expect(json["name"]).to eq("new-name")
user.reload user.reload
expect(user.name).to eq('new-name') expect(user.name).to eq("new-name")
end
it 'returns 403 when current user is banned' do
user.update!(banned_at: Time.current)
put "/users/#{user.id}",
params: { name: 'new-name' },
headers: auth_headers(user)
expect(response).to have_http_status(:forbidden)
user.reload
expect(user.name).to eq('old-name')
end
it 'returns 403 when current IP address is banned' do
IpAddress.create!(
ip_address: IPAddr.new(remote_ip).hton,
banned_at: Time.current
)
put "/users/#{user.id}",
params: { name: 'new-name' },
headers: auth_headers(user)
expect(response).to have_http_status(:forbidden)
user.reload
expect(user.name).to eq('old-name')
end end
end end
describe 'POST /users/verify' do describe "POST /users/verify" do
it 'returns valid:false when code not found' do it "returns valid:false when code not found" do
post '/users/verify', params: { code: 'nope' } post "/users/verify", params: { code: "nope" }
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
expect(json['valid']).to eq(false) expect(json["valid"]).to eq(false)
end end
it 'returns 403 when current IP address is banned' do it "creates IpAddress and UserIp, and returns valid:true with user slice" do
user = create(:user, inheritance_code: SecureRandom.uuid, role: 'guest') user = create(:user, inheritance_code: SecureRandom.uuid, role: "guest")
IpAddress.create!( # request.remote_ip を固定
ip_address: IPAddr.new(remote_ip).hton, allow_any_instance_of(ActionDispatch::Request).to receive(:remote_ip).and_return("203.0.113.10")
banned_at: Time.current
)
expect { expect {
post '/users/verify', params: { code: user.inheritance_code } post "/users/verify", params: { code: user.inheritance_code }
}.not_to change(UserIp, :count)
expect(response).to have_http_status(:forbidden)
end
it 'returns 403 when verified user is banned' do
user = create(
:user,
:banned,
inheritance_code: SecureRandom.uuid,
role: 'guest'
)
expect {
post '/users/verify', params: { code: user.inheritance_code }
}.not_to change(UserIp, :count)
expect(response).to have_http_status(:forbidden)
end
it 'creates IpAddress and UserIp, and returns valid:true with user slice' do
user = create(:user, inheritance_code: SecureRandom.uuid, role: 'guest')
expect {
post '/users/verify', params: { code: user.inheritance_code }
}.to change(UserIp, :count).by(1)
.and change(IpAddress, :count).by(1)
expect(response).to have_http_status(:ok)
expect(json['valid']).to eq(true)
expect(json['user']['id']).to eq(user.id)
expect(json['user']['inheritance_code']).to eq(user.inheritance_code)
expect(json['user']['role']).to eq('guest')
ip_address = IpAddress.find_by(ip_address: IPAddr.new(remote_ip).hton)
expect(ip_address).to be_present
expect(UserIp.exists?(user:, ip_address:)).to eq(true)
end
it 'is idempotent for same user and same IP address' do
user = create(:user, inheritance_code: SecureRandom.uuid, role: 'guest')
post '/users/verify', params: { code: user.inheritance_code }
expect(response).to have_http_status(:ok)
expect {
post '/users/verify', params: { code: user.inheritance_code }
}.not_to change(UserIp, :count)
expect(response).to have_http_status(:ok)
expect(json['valid']).to eq(true)
end
it 'creates another UserIp for same user and different IP address' do
user = create(:user, inheritance_code: SecureRandom.uuid, role: 'guest')
post '/users/verify', params: { code: user.inheritance_code }
expect(response).to have_http_status(:ok)
allow_any_instance_of(ActionDispatch::Request)
.to receive(:remote_ip)
.and_return('203.0.113.11')
expect {
post '/users/verify', params: { code: user.inheritance_code }
}.to change(UserIp, :count).by(1) }.to change(UserIp, :count).by(1)
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
expect(json['valid']).to eq(true) expect(json["valid"]).to eq(true)
expect(json["user"]["id"]).to eq(user.id)
expect(json["user"]["inheritance_code"]).to eq(user.inheritance_code)
expect(json["user"]["role"]).to eq("guest")
# ついでに IpAddress もできてることを確認(ipの保存形式がバイナリでも count で見れる)
expect(IpAddress.count).to be >= 1
end
it "is idempotent for same user+ip (does not create duplicate UserIp)" do
user = create(:user, inheritance_code: SecureRandom.uuid, role: "guest")
allow_any_instance_of(ActionDispatch::Request).to receive(:remote_ip).and_return("203.0.113.10")
post "/users/verify", params: { code: user.inheritance_code }
expect(response).to have_http_status(:ok)
expect {
post "/users/verify", params: { code: user.inheritance_code }
}.not_to change(UserIp, :count)
expect(response).to have_http_status(:ok)
expect(json["valid"]).to eq(true)
end end
end end
describe 'GET /users/me' do describe "GET /users/me" do
it 'returns 404 when code not found' do it "returns 404 when code not found" do
get '/users/me', params: { code: 'nope' } get "/users/me", params: { code: "nope" }
expect(response).to have_http_status(:not_found) expect(response).to have_http_status(:not_found)
end end
it 'returns user slice when found' do it "returns user slice when found" do
user = create(:user, inheritance_code: SecureRandom.uuid, name: 'me', role: 'guest') user = create(:user, inheritance_code: SecureRandom.uuid, name: "me", role: "guest")
get "/users/me", params: { code: user.inheritance_code }
get '/users/me', params: { code: user.inheritance_code }
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
expect(json['id']).to eq(user.id) expect(json["id"]).to eq(user.id)
expect(json['name']).to eq('me') expect(json["name"]).to eq("me")
expect(json['inheritance_code']).to eq(user.inheritance_code) expect(json["inheritance_code"]).to eq(user.inheritance_code)
expect(json['role']).to eq('guest') expect(json["role"]).to eq("guest")
end
it 'returns 403 when current IP address is banned' do
user = create(:user, inheritance_code: SecureRandom.uuid)
IpAddress.create!(
ip_address: IPAddr.new(remote_ip).hton,
banned_at: Time.current
)
get '/users/me', params: { code: user.inheritance_code }
expect(response).to have_http_status(:forbidden)
end end
end end
end end
+1 -1
ファイルの表示
@@ -4,7 +4,7 @@ RSpec.describe 'Wiki body search', type: :request do
let!(:user) { create_member_user! } let!(:user) { create_member_user! }
it 'searches wiki pages by body text' do it 'searches wiki pages by body text' do
pending '#336 で対応予定' pending 'Wiki 本文検索実装時に有効化する'
Wiki::Commit.create_content!( Wiki::Commit.create_content!(
tag_name: TagName.create!(name: 'wiki_body_search_hit'), tag_name: TagName.create!(name: 'wiki_body_search_hit'),
+1 -1
ファイルの表示
@@ -8,7 +8,7 @@ RSpec.describe 'Wiki restore', type: :request do
end end
it 'restores wiki page to previous version' do it 'restores wiki page to previous version' do
pending '#337 で対応予定' pending 'Wiki 版巻き戻し API 実装時に有効化する'
page = page =
Wiki::Commit.create_content!( Wiki::Commit.create_content!(
-85
ファイルの表示
@@ -1,85 +0,0 @@
require 'rails_helper'
RSpec.describe VersionRecorder do
let(:member) { create(:user, :member) }
let(:post_record) do
Post.create!(
title: 'version recorder post',
url: 'https://example.com/version-recorder-post')
end
it 'updates record version_no when creating the first version' do
version =
PostVersionRecorder.record!(
post: post_record,
event_type: :create,
created_by_user: member)
expect(version.version_no).to eq(1)
expect(post_record.reload.version_no).to eq(1)
end
it 'updates record version_no when creating the next version' do
PostVersionRecorder.record!(
post: post_record,
event_type: :create,
created_by_user: member)
post_record.update!(title: 'updated version recorder post')
version =
PostVersionRecorder.record!(
post: post_record.reload,
event_type: :update,
created_by_user: member)
expect(version.version_no).to eq(2)
expect(post_record.reload.version_no).to eq(2)
end
it 'does not create a new version or advance version_no when snapshot is unchanged' do
first =
PostVersionRecorder.record!(
post: post_record,
event_type: :create,
created_by_user: member)
expect {
version =
PostVersionRecorder.record!(
post: post_record.reload,
event_type: :update,
created_by_user: member)
expect(version).to eq(first)
}.not_to change(PostVersion, :count)
expect(post_record.reload.version_no).to eq(1)
end
it 'raises when record version_no is older than the latest version' do
PostVersionRecorder.record!(
post: post_record,
event_type: :create,
created_by_user: member)
post_record.update!(title: 'updated once')
PostVersionRecorder.record!(
post: post_record.reload,
event_type: :update,
created_by_user: member)
post_record.update_columns(version_no: 1)
post_record.update!(title: 'updated with stale version_no')
expect {
PostVersionRecorder.record!(
post: post_record.reload,
event_type: :update,
created_by_user: member)
}.to raise_error(RuntimeError, /version_no/)
end
end
-130
ファイルの表示
@@ -1,130 +0,0 @@
require 'rails_helper'
RSpec.describe Youtube::ApiClient do
let(:api_key) { 'test-api-key' }
let(:client) { described_class.new(api_key:) }
describe '#search_videos' do
it 'calls YouTube search API with expected params' do
published_after = Time.zone.parse('2026-05-01 00:00:00')
published_before = Time.zone.parse('2026-05-02 00:00:00')
expect(client).to receive(:get_json).with(
'/search',
{
part: 'snippet',
type: 'video',
q: 'ぼざろクリーチャー',
order: 'date',
maxResults: 50,
regionCode: 'JP',
relevanceLanguage: 'ja',
publishedAfter: published_after.iso8601,
publishedBefore: published_before.iso8601,
pageToken: 'NEXT'
}
).and_return({ 'items' => [] })
client.search_videos(
q: 'ぼざろクリーチャー',
published_after:,
published_before:,
page_token: 'NEXT'
)
end
it 'omits nil optional params' do
expect(client).to receive(:get_json).with(
'/search',
hash_excluding(:publishedAfter, :publishedBefore, :pageToken)
).and_return({ 'items' => [] })
client.search_videos(q: 'ぼざろクリーチャー')
end
end
describe '#videos' do
it 'returns empty items when ids are empty' do
expect(client).not_to receive(:get_json)
expect(client.videos([])).to eq({ 'items' => [] })
end
it 'calls videos API with comma separated ids' do
expect(client).to receive(:get_json).with(
'/videos',
{
part: 'snippet,status,contentDetails',
id: 'video-1,video-2'
}
).and_return({ 'items' => [] })
client.videos(['video-1', 'video-2'])
end
end
describe '#playlist_items' do
it 'calls playlistItems API with page token' do
expect(client).to receive(:get_json).with(
'/playlistItems',
{
part: 'snippet,contentDetails,status',
playlistId: 'PL123',
maxResults: 50,
pageToken: 'NEXT'
}
).and_return({ 'items' => [] })
client.playlist_items(playlist_id: 'PL123', page_token: 'NEXT')
end
it 'omits page token when nil' do
expect(client).to receive(:get_json).with(
'/playlistItems',
{
part: 'snippet,contentDetails,status',
playlistId: 'PL123',
maxResults: 50
}
).and_return({ 'items' => [] })
client.playlist_items(playlist_id: 'PL123')
end
end
describe '#channel' do
it 'calls channels API by id' do
expect(client).to receive(:get_json).with(
'/channels',
{
part: 'snippet,contentDetails',
id: 'UC123'
}
).and_return({ 'items' => [] })
client.channel(id: 'UC123')
end
it 'calls channels API by handle' do
expect(client).to receive(:get_json).with(
'/channels',
{
part: 'snippet,contentDetails',
forHandle: '@some_handle'
}
).and_return({ 'items' => [] })
client.channel(handle: '@some_handle')
end
it 'raises when neither id nor handle is given' do
expect { client.channel }.to raise_error(ArgumentError, 'id or handle is required')
end
it 'raises when both id and handle are given' do
expect do
client.channel(id: 'UC123', handle: '@some_handle')
end.to raise_error(ArgumentError, 'id or handle is required')
end
end
end
-310
ファイルの表示
@@ -1,310 +0,0 @@
require 'rails_helper'
RSpec.describe Youtube::Sync do
let(:client) { instance_double(Youtube::ApiClient) }
let(:sync) { described_class.new(client:) }
before do
allow(PostVersionRecorder).to receive(:record!)
allow(PostVersionRecorder).to receive(:ensure_snapshot!)
allow(sync).to receive(:attach_thumbnail_if_needed!)
end
describe '#sync!' do
it 'returns without fetching video details when no video ids are discovered' do
allow(sync).to receive(:query_terms).and_return([])
allow(sync).to receive(:playlist_ids).and_return([])
expect(client).not_to receive(:videos)
sync.sync!
end
it 'discovers ids from search and all playlist pages' do
allow(sync).to receive(:query_terms).and_return(['ぼざろクリーチャー'])
allow(sync).to receive(:playlist_ids).and_return(['PL123'])
allow(sync).to receive(:sync_since).and_return(Time.zone.parse('2026-05-01 00:00:00'))
allow(client).to receive(:search_videos).with(
q: 'ぼざろクリーチャー',
published_after: Time.zone.parse('2026-05-01 00:00:00')
).and_return({
'items' => [
{
'id' => {
'videoId' => 'search-video-1'
}
}
]
})
allow(client).to receive(:playlist_items).with(
playlist_id: 'PL123',
page_token: nil
).and_return({
'items' => [
{
'contentDetails' => {
'videoId' => 'playlist-video-1'
}
}
],
'nextPageToken' => 'NEXT'
})
allow(client).to receive(:playlist_items).with(
playlist_id: 'PL123',
page_token: 'NEXT'
).and_return({
'items' => [
{
'snippet' => {
'resourceId' => {
'videoId' => 'playlist-video-2'
}
}
}
]
})
expect(client).to receive(:videos).with(
satisfy do |ids|
ids.sort == ['playlist-video-1', 'playlist-video-2', 'search-video-1']
end
).and_return({ 'items' => [] })
sync.sync!
end
it 'creates a YouTube post with default tags and no_deerjikist when no deerjikist mapping exists' do
Tag.tagme
Tag.bot
Tag.youtube
Tag.video
Tag.no_deerjikist
allow(sync).to receive(:query_terms).and_return([])
allow(sync).to receive(:playlist_ids).and_return(['PL123'])
allow(client).to receive(:playlist_items).with(
playlist_id: 'PL123',
page_token: nil
).and_return({
'items' => [
{
'contentDetails' => {
'videoId' => 'video-1'
}
}
]
})
allow(client).to receive(:videos).with(['video-1']).and_return({
'items' => [
youtube_video_item(
id: 'video-1',
title: 'YouTube テスト動画',
channel_id: 'UC_NO_MAPPING'
)
]
})
expect do
sync.sync!
end.to change(Post, :count).by(1)
post = Post.find_by!(url: 'https://www.youtube.com/watch?v=video-1')
tag_ids = post.tags.pluck(:id)
expect(post.title).to eq('YouTube テスト動画')
expect(post.uploaded_user_id).to be_nil
expect(post.original_created_from).to eq(Time.zone.parse('2026-05-01 12:34:00'))
expect(post.original_created_before).to eq(Time.zone.parse('2026-05-01 12:35:00'))
expect(tag_ids).to include(Tag.tagme.id)
expect(tag_ids).to include(Tag.bot.id)
expect(tag_ids).to include(Tag.youtube.id)
expect(tag_ids).to include(Tag.video.id)
expect(tag_ids).to include(Tag.no_deerjikist.id)
expect(PostVersionRecorder).to have_received(:record!).with(
post:,
event_type: :create,
created_by_user: nil
)
end
it 'uses deerjikist tag when channel id is mapped' do
Tag.tagme
Tag.bot
Tag.youtube
Tag.video
Tag.no_deerjikist
deerjikist_tag = Tag.find_or_create_by_tag_name!('テスト投稿者', category: :deerjikist)
Deerjikist.create!(
platform: 'youtube',
code: 'UC_MAPPED',
tag: deerjikist_tag
)
allow(sync).to receive(:query_terms).and_return([])
allow(sync).to receive(:playlist_ids).and_return(['PL123'])
allow(client).to receive(:playlist_items).with(
playlist_id: 'PL123',
page_token: nil
).and_return({
'items' => [
{
'contentDetails' => {
'videoId' => 'video-1'
}
}
]
})
allow(client).to receive(:videos).with(['video-1']).and_return({
'items' => [
youtube_video_item(
id: 'video-1',
title: 'YouTube テスト動画',
channel_id: 'UC_MAPPED'
)
]
})
sync.sync!
post = Post.find_by!(url: 'https://www.youtube.com/watch?v=video-1')
tag_ids = post.tags.pluck(:id)
expect(tag_ids).to include(deerjikist_tag.id)
expect(tag_ids).not_to include(Tag.no_deerjikist.id)
end
it 'removes no_deerjikist when deerjikist mapping is added later' do
Tag.no_deerjikist
post = Post.create!(
title: '旧タイトル',
url: 'https://www.youtube.com/watch?v=video-1',
uploaded_user_id: nil,
original_created_from: Time.zone.parse('2026-05-01 00:00:00'),
original_created_before: Time.zone.parse('2026-05-01 00:01:00')
)
PostTag.create!(post:, tag: Tag.no_deerjikist)
deerjikist_tag = Tag.find_or_create_by_tag_name!('後から判明した投稿者', category: :deerjikist)
Deerjikist.create!(
platform: 'youtube',
code: 'UC_MAPPED_LATER',
tag: deerjikist_tag
)
allow(sync).to receive(:query_terms).and_return([])
allow(sync).to receive(:playlist_ids).and_return(['PL123'])
allow(client).to receive(:playlist_items).with(
playlist_id: 'PL123',
page_token: nil
).and_return({
'items' => [
{
'contentDetails' => {
'videoId' => 'video-1'
}
}
]
})
allow(client).to receive(:videos).with(['video-1']).and_return({
'items' => [
youtube_video_item(
id: 'video-1',
title: '新タイトル',
channel_id: 'UC_MAPPED_LATER'
)
]
})
sync.sync!
post.reload
tag_ids = post.tags.pluck(:id)
expect(post.title).to eq('新タイトル')
expect(tag_ids).to include(deerjikist_tag.id)
expect(tag_ids).not_to include(Tag.no_deerjikist.id)
expect(PostVersionRecorder).to have_received(:ensure_snapshot!).with(
post,
created_by_user: nil
)
expect(PostVersionRecorder).to have_received(:record!).with(
post:,
event_type: :update,
created_by_user: nil
)
end
it 'matches existing youtu.be URL and does not create duplicate post' do
post = Post.create!(
title: '旧タイトル',
url: 'https://youtu.be/video-1',
uploaded_user_id: nil,
original_created_from: Time.zone.parse('2026-05-01 00:00:00'),
original_created_before: Time.zone.parse('2026-05-01 00:01:00')
)
allow(sync).to receive(:query_terms).and_return([])
allow(sync).to receive(:playlist_ids).and_return(['PL123'])
allow(client).to receive(:playlist_items).with(
playlist_id: 'PL123',
page_token: nil
).and_return({
'items' => [
{
'contentDetails' => {
'videoId' => 'video-1'
}
}
]
})
allow(client).to receive(:videos).with(['video-1']).and_return({
'items' => [
youtube_video_item(
id: 'video-1',
title: '新タイトル',
channel_id: 'UC_NO_MAPPING'
)
]
})
expect do
sync.sync!
end.not_to change(Post, :count)
expect(post.reload.title).to eq('新タイトル')
end
end
def youtube_video_item(id:, title:, channel_id:)
{
'id' => id,
'snippet' => {
'title' => title,
'channelId' => channel_id,
'publishedAt' => '2026-05-01T12:34:56Z',
'thumbnails' => {
'high' => {
'url' => "https://img.youtube.com/#{id}.jpg"
}
},
'tags' => ['tag-a', 'tag-b']
}
}
end
end
-93
ファイルの表示
@@ -1,93 +0,0 @@
require 'rails_helper'
RSpec.describe Youtube::VideoItem do
describe '#initialize' do
it 'extracts fields from YouTube video API item' do
item = {
'id' => 'video-1',
'snippet' => {
'title' => 'テスト動画',
'channelId' => 'UC123',
'publishedAt' => '2026-05-01T12:34:56Z',
'tags' => ['tag-a', 'tag-b'],
'thumbnails' => {
'high' => {
'url' => 'https://img.youtube.com/high.jpg'
},
'medium' => {
'url' => 'https://img.youtube.com/medium.jpg'
}
}
}
}
video = described_class.new(item)
expect(video.id).to eq('video-1')
expect(video.title).to eq('テスト動画')
expect(video.channel_id).to eq('UC123')
expect(video.published_at).to eq(Time.iso8601('2026-05-01T12:34:56Z'))
expect(video.thumbnail_url).to eq('https://img.youtube.com/high.jpg')
expect(video.raw_tags).to eq(['tag-a', 'tag-b'])
expect(video.url).to eq('https://www.youtube.com/watch?v=video-1')
end
it 'uses highest priority thumbnail' do
item = {
'id' => 'video-1',
'snippet' => {
'title' => 'テスト動画',
'channelId' => 'UC123',
'publishedAt' => '2026-05-01T12:34:56Z',
'thumbnails' => {
'default' => {
'url' => 'https://img.youtube.com/default.jpg'
},
'standard' => {
'url' => 'https://img.youtube.com/standard.jpg'
},
'maxres' => {
'url' => 'https://img.youtube.com/maxres.jpg'
}
}
}
}
video = described_class.new(item)
expect(video.thumbnail_url).to eq('https://img.youtube.com/maxres.jpg')
end
it 'falls back to empty raw tags' do
item = {
'id' => 'video-1',
'snippet' => {
'title' => 'テスト動画',
'channelId' => 'UC123',
'publishedAt' => '2026-05-01T12:34:56Z',
'thumbnails' => {}
}
}
video = described_class.new(item)
expect(video.raw_tags).to eq([])
end
it 'returns nil thumbnail when no thumbnail exists' do
item = {
'id' => 'video-1',
'snippet' => {
'title' => 'テスト動画',
'channelId' => 'UC123',
'publishedAt' => '2026-05-01T12:34:56Z',
'thumbnails' => {}
}
}
video = described_class.new(item)
expect(video.thumbnail_url).to be_nil
end
end
end
+4 -2
ファイルの表示
@@ -2,12 +2,14 @@ module TestRecords
def create_member_user! def create_member_user!
User.create!(name: 'spec user', User.create!(name: 'spec user',
inheritance_code: SecureRandom.hex(16), inheritance_code: SecureRandom.hex(16),
role: 'member') role: 'member',
banned: false)
end end
def create_admin_user! def create_admin_user!
User.create!(name: 'spec admin', User.create!(name: 'spec admin',
inheritance_code: SecureRandom.hex(16), inheritance_code: SecureRandom.hex(16),
role: 'admin') role: 'admin',
banned: false)
end end
end end
+1 -1
ファイルの表示
@@ -104,7 +104,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),
parent_post_ids: post.snapshot_parent_post_ids.join(' '), parent: post.parent,
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,
created_at: Time.current, created_at: Time.current,
-25
ファイルの表示
@@ -1,25 +0,0 @@
require 'rails_helper'
require 'rake'
RSpec.describe 'post:sync' do
around do |example|
original_application = Rake.application
Rake.application = Rake::Application.new
Rake::Task.define_task(:environment)
load Rails.root.join('lib/tasks/sync_posts.rake')
example.run
ensure
Rake.application = original_application
end
it 'runs Youtube::Sync' do
sync = instance_double(Youtube::Sync)
expect(Youtube::Sync).to receive(:new).once.and_return(sync)
expect(sync).to receive(:sync!).once
Rake::Task['post:sync'].invoke
end
end
-646
ファイルの表示
@@ -1,646 +0,0 @@
# Codex handoff for BTRC Hub / タグ広場
This document transfers project-specific context from prior ChatGPT-assisted design and review work to Codex.
Use this file as project background.
Use `AGENTS.md`, `backend/AGENTS.md`, and `frontend/AGENTS.md` for concrete coding rules and verification commands.
## Project identity
BTRC Hub / タグ広場 is a collaborative knowledge base for collecting, tagging, explaining, and rediscovering Bocchi the Rock creature-related works.
It is not a generic SNS.
It is not a comment board.
It is not a service for rehosting external content.
It is primarily a structured link, tag, wiki, material, and viewing-party system.
Core domains:
1. Posts
2. Tags
3. Wiki pages
4. Materials
5. Theatre / watch-party features
The project is already publicly accessible and indexed by search engines, but it has not been broadly announced. Treat it as a small public production system, not a private prototype.
## Current stack
Backend:
- Ruby 3.2.2
- Rails 8.0.2 API
- MySQL 8
- Active Storage
- Cloudflare R2 / S3-compatible storage is expected for uploaded files
- RSpec
Frontend:
- React 19.1
- Vite 6.3
- TypeScript 5.8
- Axios
- TanStack Query
- Tailwind CSS
- Framer Motion
- shadcn-like local components
- react-markdown
- react-markdown-editor-lite
- remark-wiki-autolink
Batch / background-like tasks:
- Rake tasks
- Nico sync
- YouTube sync
- Similarity calculation tasks
## Repository working principle
Before editing, inspect the existing implementation.
Do not invent a new architecture when the current repo already has an established convention.
Keep changes scoped to the requested issue.
Prefer small, reviewable changes over broad rewrites.
Do not perform unrelated cleanup in the same patch.
When a task has design ambiguity, first produce a short investigation and recommended plan. Do not silently choose a risky design.
## User coding preferences
General:
- Prefer single quotes for strings unless interpolation, escaping, or framework convention makes double quotes better.
- Do not add production dependencies without explicit approval.
- Do not perform broad formatting churn.
- Do not convert unrelated files to a different style.
Ruby:
- Do not put a space before method-call parentheses.
- Do not use `%w`.
- Do not use `%i`.
- Keep Rails code idiomatic, but preserve the user's style where the repo already uses it.
TypeScript / Python:
- The user prefers GNU-style spacing before parentheses where syntactically valid.
- Preserve existing project formatting if a formatter or nearby code dictates otherwise.
## Current authentication model
The system does not use normal email/password authentication.
Users are authenticated by inheritance code.
Frontend:
- Stores the code in `localStorage.user_code`.
- Sends it as the `X-Transfer-Code` header.
Backend:
- Looks up `users.inheritance_code`.
- Sets `current_user`.
Roles:
- `guest`
- `member`
- `admin`
Important helper:
- `User#gte_member?` returns true for `member` and `admin`.
Never introduce a conventional login assumption unless the issue explicitly asks for it.
## BAN / abuse-control model
The backend currently enforces BAN at API level.
The relevant before_action order is conceptually:
1. Reject banned IP address.
2. Authenticate user if transfer code exists.
3. Reject banned user.
Entities:
- `users.banned_at`
- `ip_addresses.banned_at`
- `user_ips`
IP addresses are stored as binary values using `IPAddr#hton`.
Do not weaken BAN behavior.
Do not move BAN checks behind optional authentication.
Do not make preview, theatre, verify, user creation, or public-looking endpoints bypass BAN without an explicit design decision.
## Public-operation assumptions
Current practical operation:
- A few editor accounts exist.
- Meaningful editing is mostly done by the owner.
- Read access is already public.
- Search engines have indexed the site.
- Future editor applications are expected through Discord.
- Prospective editors are likely people known in the Bocchi creature community.
This means security and moderation issues matter even if traffic is still small.
## Core domain summary
### Posts
Posts are external URL-based link records.
Important properties:
- `url` is required and unique.
- URLs are normalized.
- Only HTTP / HTTPS are allowed.
- Posts can have thumbnails through Active Storage.
- `uploaded_user_id` may be NULL for synced or bot-created posts.
- `original_created_from` and `original_created_before` represent a time range for original content creation.
- When both original time bounds exist, `from < before` is required.
Parent/child posts:
- Current implementation uses `post_implications`.
- It is many-to-many.
- Do not assume `posts.parent_id`.
- Frontend/API clients must send `parent_post_ids`, even when empty.
- `parent_post_ids` is parsed as a space-separated ID string.
- Self-parenting is invalid.
- Missing parent IDs are invalid.
Versions:
- `post_versions` stores snapshots.
- `version_no` is a per-post sequence.
- Snapshot includes title, URL, thumbnail base, tags, parent post IDs, original time bounds, event type, and actor.
- Optimistic locking for posts is planned / important, but do not assume it is fully implemented unless the code proves it.
### Tags
Tags are central.
There is separation between tag names and tag entities:
- `tag_names`
- `tags`
Categories:
- `deerjikist`
- `meme`
- `character`
- `general`
- `material`
- `nico`
- `meta`
Alias model:
- `tag_names.canonical_id` expresses aliases.
- `canonical_id = NULL` means canonical name.
- `canonical_id != NULL` means alias.
- An alias must not point to another alias.
- A tag name that already has a tag or wiki page generally must not be aliasified.
Tag normalization:
- User-entered tags are normalized through existing backend logic.
- Known aliases are canonicalized.
- Parent tags are expanded recursively.
- `nico:` is normally rejected for manual entry.
- Special tags such as tag-request / bot / unknown-deerjikist / video / niconico / youtube must be protected.
Do not casually change tag normalization, alias resolution, or parent expansion. These affect search, wiki, sync, and historical data.
### Nico tags
Nico tags use the `nico` category and have separate versioning.
Important relation:
- `nico_tag_relations` maps external Nico tags to internal tags.
- `nico_tag_id` must be a Nico category tag.
- `tag_id` must not be Nico category.
Do not allow ordinary manual tag editing to create or corrupt Nico tags.
### Deerjikists
Deerjikists map external platform identities to internal `deerjikist` tags.
Known platforms include:
- `nico`
- `youtube`
YouTube handles may be normalized to `UC...` channel IDs.
Do not treat user-facing handles and canonical channel IDs as interchangeable without checking existing code.
### Wiki
Wiki pages are a major knowledge layer.
Important points:
- Wiki pages are tied to tag-like titles.
- Title handling, aliases, and canonical tag names matter.
- There is line-level storage / revision-oriented behavior in the current implementation.
- There has been design tension between wiki revisions and wiki versions.
- Wiki conflict detection using `base_revision_id` exists on the backend side.
- Frontend support for conflict detection must be verified before assuming it is complete.
Do not redesign Wiki storage casually.
Do not add a second competing history system.
Do not break existing wiki URLs.
### Materials
Materials connect files or reference URLs to `material` or `character` tags.
Important properties:
- A material has a `tag_id`.
- The tag must be `material` or `character`.
- A material requires either `url` or attached `file`.
- Active Storage is involved.
- Upload/security policy matters more than plain link posting.
Important unresolved/risky area:
- Material creation permissions have historically been risky because upload endpoints can be abused.
- Prefer `member` or higher for material creation unless the issue explicitly says otherwise.
### Theatre
Theatre is an experimental watch-party style feature.
Known pieces include:
- Display
- Presence
- Next post
- Comments
- Host-like control
Do not assume theatre has complete CRUD/admin support unless the code proves it.
Theatre may become expensive if next-item selection uses random DB ordering.
## Current high-risk areas
Treat these areas with extra care.
### Security
- Preview API SSRF protection.
- External iframe / embed CSP.
- Markdown link safety.
- BAN / IP BAN bypass.
- Transfer-code leakage.
- Guest write access.
- Upload endpoints.
- Admin-only tag operations.
- System tag mutation.
### Data integrity
- Tag alias canonicalization.
- Tag parent expansion.
- Post parent many-to-many relationships.
- Version tables.
- `version_no` synchronization.
- Schema drift from branch migration contamination.
- Wiki revision/version split.
- Material version recording.
### Frontend correctness
- React Hooks must not be called conditionally.
- Role guards are currently spread across components/pages.
- TanStack Query keys must not collide between ID/name or ID/title variants.
- URL path segments containing tag names or wiki titles must use `encodeURIComponent`.
- API response types may allow `null` users for bot or migration data.
- Tag autocomplete has had duplicated logic and stale state hazards.
### Performance
- Avoid unbounded `limit`.
- Avoid `order('RAND()')` for growing tables.
- Avoid loading full relations just to count.
- Avoid Ruby-side sorting/paging for large histories.
- Tag sidebar client-side aggregation can become expensive.
- Wiki full-text search needs deliberate indexing/design.
## Current priority order
Use this as the default priority unless an issue says otherwise.
### P0: Safety before broad announcement
1. Preview API SSRF hardening.
2. Material creation permission tightening.
3. System tag mutation holes.
4. `GET /users/me` transfer-code leakage through query params.
5. Limit caps for index/history/comment APIs.
6. CSP / iframe sandbox policy.
7. Confirm BAN enforcement remains global.
### P1: Core correctness
1. Post optimistic locking with `version_no`.
2. Wiki edit conflict handling.
3. Wiki history/revision model clarification.
4. Wiki search truthfulness: implement body search or remove false UI.
5. Tag alias/canonical/wiki interaction.
6. Tag URL encoding.
7. TanStack Query key separation.
8. Frontend null-user handling.
9. React Hooks rule fixes.
10. Material version policy.
### P2: Operational/admin usability
1. Admin screens for users, IPs, bans, aliases, and settings.
2. Settings table and user settings usage.
3. Better tag sidebar.
4. Better role guard helpers.
5. Better frontend tests.
6. Better issue triage and closure of already-implemented issues.
### P3: Future features
1. Theatre list/create/edit/admin flow.
2. Muted/hidden tags.
3. Tag category custom colors.
4. Responsive refinements.
5. Watch-party improvements.
6. Broader embed support.
## Known issue triage notes
Some existing issues may already be partially or mostly implemented.
Before implementing an issue, check code first.
Examples:
- Tag search and OR/NOT search may already be mostly implemented.
- BAN enforcement may have been implemented after earlier issue drafts.
- YouTube sync exists and should not be treated as purely planned.
- Parent posts are many-to-many in current schema, even if older issues mention one-to-many.
- Some issues may reflect old schema or old branch state.
When in doubt:
1. Inspect current code.
2. Inspect schema.
3. Inspect routes.
4. Inspect frontend usage.
5. Report whether the issue is implemented, partially implemented, not implemented, or obsolete.
6. Only then edit.
## Verification expectations
Backend changes:
- Run RSpec when possible.
- Add request specs for API behavior changes.
- Add model specs for validation / normalization changes.
- Check migrations and schema consistency.
- Do not silently ignore pending migrations.
Frontend changes:
- Run build.
- Run lint if configured.
- Run tests if configured.
- Add tests for important behavior when the test framework exists.
- If frontend tests are not yet installed, state that clearly.
Full-stack changes:
- Verify both backend and frontend compile/test paths where possible.
- Confirm API response shapes match TypeScript types.
- Confirm authorization behavior on both server and UI.
If commands cannot be run because dependencies are missing, report that explicitly. Do not pretend verification passed.
## Branch / migration caution
The project has previously suffered from schema contamination caused by running migrations from another branch.
Be careful when touching:
- `db/schema.rb`
- migration files
- parent post schema
- banned / banned_at schema
- version_no migrations
- wiki asset schema
Before changing migrations:
1. Inspect current schema.
2. Inspect existing migrations.
3. Confirm whether the intended branch already includes related migrations.
4. Prefer additive migrations for shared branches.
5. Do not edit already-applied production migrations unless explicitly instructed.
## API design principles
Prefer explicit server-side authorization.
Do not rely only on frontend hiding.
Do not return sensitive codes unnecessarily.
Use 403 for authorization failures.
Use 422 for validation failures.
Use 409 for edit conflicts.
Do not expose internal exception messages to users.
Clamp or reject abusive limits consistently.
Keep response shape stable unless the issue explicitly includes a breaking API change.
## Frontend design principles
Use existing route and query-key conventions.
Use TanStack Query `enabled` rather than conditional hook calls.
Do not let role-based early returns change hook order.
Centralize repeated tag autocomplete logic when touching it.
Use `encodeURIComponent` for tag names and wiki titles in URL path segments.
Prefer graceful fallback for nullable actors:
- bot operation
- deleted user
- migration-created data
- external sync
Do not assume all API user fields are non-null.
## Testing priorities to add over time
Frontend tests are especially important because the backend already has more mature RSpec coverage.
Suggested first frontend tests:
1. Tag autocomplete.
2. Post form tag editing.
3. Tag URL encoding.
4. Wiki edit conflict UI.
5. Role guard behavior.
6. Null-user history rendering.
7. Dialog behavior.
8. Top navigation responsive behavior.
Backend test priorities:
1. BAN enforcement across public-looking endpoints.
2. Material permissions.
3. Preview SSRF rejection.
4. System tag protection.
5. Post optimistic locking.
6. Wiki conflict detection.
7. Tag alias/canonical behavior.
8. Limit caps.
9. Parent post parsing.
10. Version recorder behavior.
## What Codex should not do without explicit approval
Do not:
- Replace Rails.
- Replace React.
- Replace TanStack Query.
- Redesign the database.
- Rewrite Wiki storage.
- Remove version tables.
- Change authentication model.
- Change role names.
- Change tag category names.
- Add background job infrastructure.
- Add a new UI framework.
- Add a new test framework if one already exists.
- Add major dependencies.
- Change public URL design.
- Change production storage configuration.
- Remove historical data behavior.
- Simplify BAN/security checks.
- Treat the site as private-only.
## Good first Codex tasks
Start with investigation-only tasks.
Example:
```txt
Inspect the repository and summarize the Rails, React, TypeScript, and test setup.
Do not modify files.
List commands that actually exist in this repository.
List risks Codex should know before editing.
```
Then small safe patches:
```txt
Fix a React Hooks rule violation in one file.
Keep behavior unchanged.
Run the relevant frontend verification commands.
```
```txt
Add encodeURIComponent around one tag-name URL path segment.
Add or update a test if the project has a frontend test setup.
Run build/lint.
```
```txt
Add a request spec for a known authorization rule.
Do not change implementation unless the spec fails for the expected reason.
```
Avoid starting with:
- Wiki history redesign.
- Post versioning redesign.
- Full admin screen suite.
- Broad frontend refactor.
- Database cleanup.
- Authentication rewrite.
## Relationship with ChatGPT
ChatGPT has been used for:
- Design review.
- Risk analysis.
- Prioritization.
- Specification reconstruction.
- Migration/locking discussions.
- Codex migration planning.
Codex should be used mainly for:
- Repository inspection.
- Localized implementation.
- Test addition.
- Running verification commands.
- Producing small reviewable diffs.
For ambiguous architecture, Codex should stop and present options rather than implement a guessed design.
## Current strategic stance
The project should not be rewritten from scratch.
The current Rails + React system is acceptable.
The immediate goal is not elegance.
The immediate goal is safe public operation, data integrity, and maintainable incremental improvement.
Priority is:
1. Prevent abuse/security incidents.
2. Preserve data correctness.
3. Make editing safe for multiple users.
4. Add tests around fragile frontend behavior.
5. Improve admin/operation workflows.
6. Optimize performance after obvious dangerous patterns are removed.
## Final rule
When current code, old specs, issue drafts, and memory disagree, current code wins.
When current code is unsafe, write that explicitly and propose a small safe fix.
When the task is too broad, split it.
When verification cannot be performed, say exactly what was not verified.
-30
ファイルの表示
@@ -1,30 +0,0 @@
# Commands
## Backend
```sh
cd backend
bundle install
bundle exec rails db:migrate
bundle exec rspec
bundle exec rails routes
```
## Frontend
```sh
cd frontend
npm install
npm run dev
npm run build
npm run lint
npm run test
npm run test:run
```
### Full verification
```sh
cd backend && bundle exec rspec
cd ../frontend && npm run test:run && npm run build && npm run lint
```
-80
ファイルの表示
@@ -1,80 +0,0 @@
# Issue workflow
## Source of truth
Gitea Issues are the source of truth for tasks, discussions, labels, milestones, and status.
Do not copy the full backlog into git.
Repository documents may define:
- issue templates
- triage rules
- Codex task format
- verification rules
- release checklist
## Labels
Recommended labels:
- `P0`
- `P1`
- `P2`
- `P3`
- `security`
- `data-integrity`
- `backend`
- `frontend`
- `wiki`
- `tags`
- `materials`
- `theatre`
- `codex-ready`
- `needs-design`
- `blocked`
- `good-first-codex-task`
## Codex-ready criteria
An issue can be labeled `codex-ready` only when it has:
- clear background
- target area
- concrete tasks
- acceptance criteria
- verification commands
- explicit non-goals
- no unresolved architecture decision
## Workflow
1. Create or refine the issue in Gitea.
2. Add labels and milestone.
3. If design is unclear, label `needs-design`.
4. Discuss design before implementation.
5. When scoped enough, label `codex-ready`.
6. Give Codex the issue URL or copied issue body.
7. Codex creates a branch.
8. Codex implements a small patch.
9. Codex runs verification commands.
10. Human reviews the diff.
11. Merge.
12. Close the issue from the PR/commit message.
## Commit message
Use issue references when possible:
```txt
fix: prevent preview SSRF
Refs: #123
```
or
```
fix: prevent preview SSRF
Closes: #123
```
depending on whether the change fully resolves the issue.
-8
ファイルの表示
@@ -1,8 +0,0 @@
# Release checklist
- [ ] Backend specs pass
- [ ] Frontend build passes
- [ ] No pending migrations
- [ ] Preview API SSRF checked
- [ ] BAN behavior checked
- [ ] CSP checked
-8
ファイルの表示
@@ -1,8 +0,0 @@
# Roadmap
## Public announcement readiness
- Harden preview API
- Tighten material creation permission
- Add admin MVP
- Improve frontend tests
-147
ファイルの表示
@@ -1,147 +0,0 @@
# frontend/AGENTS.md
## Scope
These rules apply to work under `frontend/`.
This is a Vite + React + TypeScript app using TanStack Query, Tailwind CSS,
Framer Motion, Radix UI-style components, MDX, and Zustand.
## Commands
Use only scripts that exist in `package.json`:
```sh
npm run dev
npm run build
npm run lint
npm run preview
```
`npm run build` runs `tsc -b && vite build`, and `postbuild` runs
`node scripts/generate-sitemap.js`.
There is currently no `test` script in `package.json`. Do not run or report
`npm test` unless a test script is added.
After frontend changes, run:
```sh
npm run build
npm run lint
```
If either command cannot be run or fails, report the exact command and failure.
## TypeScript
- TypeScript is strict. `tsconfig.app.json` enables `strict`,
`noUnusedLocals`, `noUnusedParameters`, `erasableSyntaxOnly`,
`noFallthroughCasesInSwitch`, and `noUncheckedSideEffectImports`.
- Keep types explicit at module boundaries, API helpers, and exported utilities.
- Use `import type` for type-only imports.
- Prefer existing shared types from `src/types.ts` before adding local duplicate types.
- Preserve the repository's existing spacing style in TypeScript, including
GNU-style spacing before call parentheses where it is already used.
- Prefer single quotes for strings unless interpolation or escaping makes double quotes better.
- Never write a TypeScript or TSX line longer than 99 characters.
- Aim to keep TypeScript and TSX lines within 79 characters where practical.
- Use 4-space logical indentation in TypeScript and TSX.
- For arrays, never put whitespace or a line break immediately before `]`.
- Keep the first element on the same line as `[` by default.
- If an array would exceed the line limit, break after `[` and indent
elements by 4 spaces.
- In TypeScript and TSX only, replace every leading run of 8 spaces with a tab
to reduce bytes.
- Treat one leading tab as exactly equivalent to 8 leading spaces.
- Use tabs only for leading indentation. Never replace spaces that occur after
a non-space character on the same line.
## React
- Use function components.
- Existing page components commonly export an anonymous function satisfying
`FC`; match nearby file style when editing.
- React hooks must be called unconditionally and at the top level of components or custom hooks.
- Gate editing and other privileged controls with shared permission helpers
such as `canEditContent`, instead of showing controls and relying only on a
later API failure.
- Keep page-level components under `src/pages`.
- Keep shared and feature components under `src/components`.
- Use `react-router-dom` route params and navigation patterns already present in `src/App.tsx`.
- Encode URL path-segment values with `encodeURIComponent`.
## TanStack Query
- Use `@tanstack/react-query` for server state.
- Query keys should come from `src/lib/queryKeys.ts`; add key builders there
instead of using ad hoc arrays in components.
- Fetch functions should live in domain helpers under `src/lib`, such as
`posts.ts`, `tags.ts`, or `wiki.ts`.
- Use `useQueryClient().invalidateQueries` with the shared root keys when
mutations affect cached lists or detail views.
- The app-wide `QueryClient` is configured in `src/main.tsx`; do not create
additional clients in feature code.
## API calls
- Use `src/lib/api.ts` for HTTP calls.
- The API wrapper attaches `X-Transfer-Code` from `localStorage` and converts
non-blob responses to camelCase.
- Send Rails snake_case params and request body keys where the backend expects them.
- Do not bypass the API wrapper unless there is a specific reason, such as a
third-party request outside the Rails API.
- For blob responses, pass `responseType: 'blob'` so the wrapper does not camelCase the body.
## Imports and aliases
- The `@` alias points to `frontend/src`.
- Prefer `@/...` imports for app code instead of long relative paths.
- Keep type imports separate with `import type`.
- Match existing import grouping: external packages, app modules, then type imports.
## Tailwind and UI
- Tailwind scans `src/**/*.{html,js,ts,jsx,tsx,mdx}`.
- Use `cn` from `src/lib/utils.ts` for conditional class names and class merging.
- Reuse components from `src/components/common`, `src/components/layout`, and
`src/components/ui` before adding new primitives.
- Keep Tailwind classes consistent with nearby components.
- Prefer restrained, content-first UI chrome: avoid adding card backgrounds,
heavy borders, or nested panel decoration unless the surrounding screen
already uses them.
- Keep operational screens dense and direct; trim explanatory copy and use
short Japanese labels that fit the control.
- Preserve existing Japanese tone and orthography in nearby UI text, including
old-kana wording where the file already uses it.
- When adding dynamic tag color classes, update `tailwind.config.js` safelist
if the class cannot be statically detected.
- Do not introduce new UI libraries or production dependencies without approval.
## TSX formatting
- Preserve compact TSX expression shapes such as inline ternary branches and
closing `</div>)` forms when nearby code uses them.
- For long Tailwind `className` strings, wrap across lines only when needed.
- Keep continuation indentation aligned with the 4-space logical indentation
rule, using tabs only as leading 8-space compression.
- Do not add braces around `if`, `else`, or `for` bodies when the body is a
single physical line.
- Always add braces around `if`, `else`, or `for` bodies when the body spans
two or more physical lines, even if it is one statement.
- Avoid reformatting unrelated JSX.
## Lint and build constraints
- ESLint uses `@eslint/js`, `typescript-eslint`, `eslint-plugin-react-hooks`,
and `eslint-plugin-react-refresh`.
- The hooks rules are enforced; fix hook ordering instead of disabling the rule.
- `react-refresh/only-export-components` is enabled as a warning with `allowConstantExport`.
- Build failures from unused locals or unused parameters are TypeScript
errors, not lint-only issues.
## Files to avoid in routine work
- Do not edit `dist/` output directly.
- Do not inspect or modify `node_modules/` unless explicitly needed.
- Keep generated build artifacts out of source changes unless the user asks for them.
+1 -1
ファイルの表示
@@ -5,7 +5,7 @@ import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint' import tseslint from 'typescript-eslint'
export default tseslint.config( export default tseslint.config(
{ ignores: ['dist', 'tailwind.config.js'] }, { ignores: ['dist'] },
{ {
extends: [js.configs.recommended, ...tseslint.configs.recommended], extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'], files: ['**/*.{ts,tsx}'],
生成ファイル
+39 -1138
ファイルの表示
ファイル差分が大きすぎるため省略します 差分を読込み
+1 -9
ファイルの表示
@@ -8,8 +8,6 @@
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"postbuild": "node scripts/generate-sitemap.js", "postbuild": "node scripts/generate-sitemap.js",
"lint": "eslint .", "lint": "eslint .",
"test": "vitest",
"test:run": "vitest run",
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
@@ -47,10 +45,6 @@
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.25.0", "@eslint/js": "^9.25.0",
"@tailwindcss/typography": "^0.5.19", "@tailwindcss/typography": "^0.5.19",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/axios": "^0.14.4", "@types/axios": "^0.14.4",
"@types/markdown-it": "^14.1.2", "@types/markdown-it": "^14.1.2",
"@types/mdx": "^2.0.13", "@types/mdx": "^2.0.13",
@@ -64,13 +58,11 @@
"eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.19", "eslint-plugin-react-refresh": "^0.4.19",
"globals": "^16.0.0", "globals": "^16.0.0",
"jsdom": "^26.1.0",
"postcss": "^8.5.3", "postcss": "^8.5.3",
"tailwindcss": "^3.4.13", "tailwindcss": "^3.4.13",
"typescript": "~5.8.3", "typescript": "~5.8.3",
"typescript-eslint": "^8.30.1", "typescript-eslint": "^8.30.1",
"vite": "^6.3.5", "vite": "^6.3.5"
"vitest": "^4.1.5"
}, },
"description": "This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.", "description": "This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.",
"main": "eslint.config.js", "main": "eslint.config.js",
+3 -13
ファイルの表示
@@ -8,10 +8,8 @@ import { BrowserRouter,
import RouteBlockerOverlay from '@/components/RouteBlockerOverlay' import RouteBlockerOverlay from '@/components/RouteBlockerOverlay'
import TopNav from '@/components/TopNav' import TopNav from '@/components/TopNav'
import DialogueProvider from '@/components/dialogues/DialogueProvider'
import { Toaster } from '@/components/ui/toaster' import { Toaster } from '@/components/ui/toaster'
import { apiPost, isApiError } from '@/lib/api' import { apiPost, isApiError } from '@/lib/api'
import DeerjikistDetailPage from '@/pages/deerjikists/DeerjikistDetailPage'
import MaterialBasePage from '@/pages/materials/MaterialBasePage' import MaterialBasePage from '@/pages/materials/MaterialBasePage'
import MaterialDetailPage from '@/pages/materials/MaterialDetailPage' import MaterialDetailPage from '@/pages/materials/MaterialDetailPage'
import MaterialListPage from '@/pages/materials/MaterialListPage' import MaterialListPage from '@/pages/materials/MaterialListPage'
@@ -60,11 +58,9 @@ const RouteTransitionWrapper = ({ user, setUser }: {
<Route path="/posts/changes" element={<PostHistoryPage/>}/> <Route path="/posts/changes" element={<PostHistoryPage/>}/>
<Route path="/tags" element={<TagListPage/>}/> <Route path="/tags" element={<TagListPage/>}/>
<Route path="/tags/:id" element={<TagDetailPage/>}/> <Route path="/tags/:id" element={<TagDetailPage/>}/>
<Route path="/tags/:id/deerjikists" element={<DeerjikistDetailPage/>}/>
<Route path="/tags/nico" element={<NicoTagListPage user={user}/>}/> <Route path="/tags/nico" element={<NicoTagListPage user={user}/>}/>
<Route path="/nico/tags" element={<NicoTagListPage user={user}/>}/>
<Route path="/tags/changes" element={<TagHistoryPage/>}/> <Route path="/tags/changes" element={<TagHistoryPage/>}/>
<Route path="/theatres/:id" element={<TheatreDetailPage user={user}/>}/> <Route path="/theatres/:id" element={<TheatreDetailPage/>}/>
<Route path="/materials" element={<MaterialBasePage/>}> <Route path="/materials" element={<MaterialBasePage/>}>
<Route index element={<MaterialListPage/>}/> <Route index element={<MaterialListPage/>}/>
<Route path="new" element={<MaterialNewPage/>}/> <Route path="new" element={<MaterialNewPage/>}/>
@@ -94,7 +90,7 @@ const PostDetailRoute = ({ user }: { user: User | null }) => {
} }
const App: FC = () => { export default (() => {
const [user, setUser] = useState<User | null> (null) const [user, setUser] = useState<User | null> (null)
const [status, setStatus] = useState (200) const [status, setStatus] = useState (200)
@@ -140,9 +136,7 @@ const App: FC = () => {
return ( return (
<> <>
<RouteBlockerOverlay/> <RouteBlockerOverlay/>
<BrowserRouter> <BrowserRouter>
<DialogueProvider>
<LayoutGroup> <LayoutGroup>
<motion.div <motion.div
layout="position" layout="position"
@@ -152,11 +146,7 @@ const App: FC = () => {
<RouteTransitionWrapper user={user} setUser={setUser}/> <RouteTransitionWrapper user={user} setUser={setUser}/>
</motion.div> </motion.div>
</LayoutGroup> </LayoutGroup>
<Toaster/> <Toaster/>
</DialogueProvider>
</BrowserRouter> </BrowserRouter>
</>) </>)
} }) satisfies FC
export default App
+2 -4
ファイルの表示
@@ -19,7 +19,7 @@ type Props = {
sp?: boolean } sp?: boolean }
const DraggableDroppableTagRow: FC<Props> = ({ tag, nestLevel, pathKey, parentTagId, suppressClickRef, sp }) => { export default (({ tag, nestLevel, pathKey, parentTagId, suppressClickRef, sp }: Props) => {
const dndId = `tag-node:${ pathKey }` const dndId = `tag-node:${ pathKey }`
const downPosRef = useRef<{ x: number; y: number } | null> (null) const downPosRef = useRef<{ x: number; y: number } | null> (null)
@@ -96,6 +96,4 @@ const DraggableDroppableTagRow: FC<Props> = ({ tag, nestLevel, pathKey, parentTa
<TagLink tag={tag} nestLevel={nestLevel}/> <TagLink tag={tag} nestLevel={nestLevel}/>
</motion.div> </motion.div>
</div>) </div>)
} }) satisfies FC<Props>
export default DraggableDroppableTagRow
-32
ファイルの表示
@@ -1,32 +0,0 @@
import { render, screen } from '@testing-library/react'
import { HelmetProvider } from 'react-helmet-async'
import { describe, expect, it } from 'vitest'
import ErrorScreen from '@/components/ErrorScreen'
describe ('ErrorScreen', () => {
it.each ([
[403, '権限ないよ(笑)'],
[404, 'ページないよ(笑)'],
[500, '鯖でエラー出たって(嘲笑)'],
[503, '鯖死んでるよ(泣)'],
]) ('renders status %s', (status, message) => {
render (
<HelmetProvider>
<ErrorScreen status={status}/>
</HelmetProvider>,
)
expect (screen.getByText (String (status))).toBeInTheDocument ()
expect (screen.getByText (message)).toBeInTheDocument ()
expect (screen.getByAltText ('逃げたギター')).toBeInTheDocument ()
})
it ('throws for unsupported statuses', () => {
expect (() => render (
<HelmetProvider>
<ErrorScreen status={418}/>
</HelmetProvider>,
)).toThrow ()
})
})
+2 -4
ファイルの表示
@@ -10,7 +10,7 @@ import type { FC } from 'react'
type Props = { status: number } type Props = { status: number }
const ErrorScreen: FC<Props> = ({ status }) => { export default (({ status }: Props) => {
const [message, rightMsg, leftMsg]: [string, string, string] = (() => { const [message, rightMsg, leftMsg]: [string, string, string] = (() => {
switch (status) switch (status)
{ {
@@ -58,6 +58,4 @@ const ErrorScreen: FC<Props> = ({ status }) => {
<p className="mr-[-.5em]">{message}</p> <p className="mr-[-.5em]">{message}</p>
</div> </div>
</MainArea>) </MainArea>)
} }) satisfies FC<Props>
export default ErrorScreen
+2 -4
ファイルの表示
@@ -31,7 +31,7 @@ const setChildrenById = (
})) }))
const MaterialSidebar: FC = () => { export default (() => {
const [tags, setTags] = useState<TagWithDepth[]> ([]) const [tags, setTags] = useState<TagWithDepth[]> ([])
const [openTags, setOpenTags] = useState<Record<number, boolean>> ({ }) const [openTags, setOpenTags] = useState<Record<number, boolean>> ({ })
const [tagFetchedFlags, setTagFetchedFlags] = useState<Record<number, boolean>> ({ }) const [tagFetchedFlags, setTagFetchedFlags] = useState<Record<number, boolean>> ({ })
@@ -94,6 +94,4 @@ const MaterialSidebar: FC = () => {
{renderTags (tags)} {renderTags (tags)}
</ul> </ul>
</SidebarComponent>) </SidebarComponent>)
} }) satisfies FC
export default MaterialSidebar
+2 -4
ファイルの表示
@@ -1,11 +1,9 @@
import type { FC } from 'react' import type { FC } from 'react'
const MenuSeparator: FC = () => ( export default (() => (
<> <>
<span className="hidden md:inline flex items-center px-2">|</span> <span className="hidden md:inline flex items-center px-2">|</span>
<hr className="block md:hidden w-full opacity-25 <hr className="block md:hidden w-full opacity-25
border-t border-black dark:border-white"/> border-t border-black dark:border-white"/>
</>) </>)) satisfies FC
export default MenuSeparator
-83
ファイルの表示
@@ -1,83 +0,0 @@
import { act, fireEvent, render } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createRef } from 'react'
import NicoViewer from '@/components/NicoViewer'
import type { NiconicoViewerHandle } from '@/types'
describe ('NicoViewer', () => {
afterEach (() => {
vi.useRealTimers ()
})
it ('does not time out after metadata reports a playable duration', () => {
vi.useFakeTimers ()
const onError = vi.fn ()
const onMetadataChange = vi.fn ()
const { container } = render (
<NicoViewer
id="sm12345"
width={640}
height={360}
onMetadataChange={onMetadataChange}
onError={onError}/>,
)
const iframe = container.querySelector ('iframe')
expect (iframe).not.toBeNull ()
fireEvent.load (iframe!)
act (() => {
window.dispatchEvent (new MessageEvent ('message', {
origin: 'https://embed.nicovideo.jp',
source: iframe!.contentWindow,
data: {
eventName: 'playerMetadataChange',
data: {
currentTime: 7,
duration: 120,
isVideoMetaDataLoaded: true,
maximumBuffered: 30,
muted: false,
showComment: true,
volume: 1,
},
},
}))
})
act (() => {
vi.advanceTimersByTime (8_000)
})
expect (onMetadataChange).toHaveBeenCalled ()
expect (onError).not.toHaveBeenCalled ()
})
it ('seeks with milliseconds', () => {
const ref = createRef<NiconicoViewerHandle> ()
const { container } = render (
<NicoViewer
ref={ref}
id="sm12345"
width={640}
height={360}/>,
)
const iframe = container.querySelector ('iframe')!
const postMessage = vi.spyOn (iframe.contentWindow!, 'postMessage')
act (() => {
ref.current!.seek (7_000)
})
expect (postMessage).toHaveBeenCalledWith (
expect.objectContaining ({
eventName: 'seek',
data: { time: 7_000 },
}),
'https://embed.nicovideo.jp',
)
})
})
+9 -55
ファイルの表示
@@ -14,20 +14,10 @@ import type { NiconicoMetadata, NiconicoVideoInfo, NiconicoViewerHandle } from '
type NiconicoPlayerMessage = type NiconicoPlayerMessage =
| { eventName: 'enterProgrammaticFullScreen' } | { eventName: 'enterProgrammaticFullScreen' }
| { eventName: 'exitProgrammaticFullScreen' } | { eventName: 'exitProgrammaticFullScreen' }
| { eventName: 'loadComplete' | { eventName: 'loadComplete'; playerId?: string; data: { videoInfo: NiconicoVideoInfo } }
playerId?: string | { eventName: 'playerMetadataChange'; playerId?: string; data: NiconicoMetadata }
data: { videoInfo: NiconicoVideoInfo } } | { eventName: 'playerStatusChange' | 'statusChange'; playerId?: string; data?: unknown }
| { eventName: 'playerMetadataChange' | { eventName: 'error'; playerId?: string; data?: unknown; code?: string; message?: string }
playerId?: string
data: NiconicoMetadata }
| { eventName: 'playerStatusChange' | 'statusChange'
playerId?: string
data?: unknown }
| { eventName: 'error'
playerId?: string
data?: unknown
code?: string
message?: string }
type NiconicoCommand = type NiconicoCommand =
| { eventName: 'play'; sourceConnectorType: 1; playerId: string } | { eventName: 'play'; sourceConnectorType: 1; playerId: string }
@@ -40,7 +30,6 @@ type NiconicoCommand =
data: { commentVisibility: boolean } } data: { commentVisibility: boolean } }
const EMBED_ORIGIN = 'https://embed.nicovideo.jp' const EMBED_ORIGIN = 'https://embed.nicovideo.jp'
const LOAD_COMPLETE_TIMEOUT_MS = 8_000
type Props = { type Props = {
id: string id: string
@@ -48,18 +37,14 @@ type Props = {
height: number height: number
style?: CSSProperties style?: CSSProperties
onLoadComplete?: (info: NiconicoVideoInfo) => void onLoadComplete?: (info: NiconicoVideoInfo) => void
onMetadataChange?: (meta: NiconicoMetadata) => void onMetadataChange?: (meta: NiconicoMetadata) => void }
onError?: (data: unknown) => void }
export default forwardRef ((props: Props, ref: ForwardedRef<NiconicoViewerHandle>) => { export default forwardRef ((props: Props, ref: ForwardedRef<NiconicoViewerHandle>) => {
const { id, width, height, style = { }, onLoadComplete, onMetadataChange, onError } = props const { id, width, height, style = { }, onLoadComplete, onMetadataChange } = props
const iframeRef = useRef<HTMLIFrameElement> (null) const iframeRef = useRef<HTMLIFrameElement> (null)
const loadCompleteTimerRef = useRef<ReturnType<typeof setTimeout> | null> (null) const playerId = useMemo (() => `nico-${ id }-${ Math.random ().toString (36).slice (2) }`, [id])
const playerId = useMemo (
() => `nico-${ id }-${ Math.random ().toString (36).slice (2) }`,
[id])
const [screenWidth, setScreenWidth] = useState<CSSProperties['width']> () const [screenWidth, setScreenWidth] = useState<CSSProperties['width']> ()
const [screenHeight, setScreenHeight] = useState<CSSProperties['height']> () const [screenHeight, setScreenHeight] = useState<CSSProperties['height']> ()
@@ -94,24 +79,6 @@ export default forwardRef ((props: Props, ref: ForwardedRef<NiconicoViewerHandle
const margedStyle: CSSProperties = const margedStyle: CSSProperties =
{ border: 'none', maxWidth: '100%', ...style, ...styleFullScreen } { border: 'none', maxWidth: '100%', ...style, ...styleFullScreen }
const clearLoadCompleteTimer = useCallback (() => {
if (!(loadCompleteTimerRef.current))
return
clearTimeout (loadCompleteTimerRef.current)
loadCompleteTimerRef.current = null
}, [])
const startLoadCompleteTimer = useCallback (() => {
clearLoadCompleteTimer ()
loadCompleteTimerRef.current = setTimeout (() => {
onError?.({
eventName: 'loadCompleteTimeout',
reason: 'niconico video length was not reported by embed',
})
}, LOAD_COMPLETE_TIMEOUT_MS)
}, [clearLoadCompleteTimer, onError])
const postToPlayer = useCallback ((message: NiconicoCommand) => { const postToPlayer = useCallback ((message: NiconicoCommand) => {
const win = iframeRef.current?.contentWindow const win = iframeRef.current?.contentWindow
if (!(win)) if (!(win))
@@ -129,9 +96,7 @@ export default forwardRef ((props: Props, ref: ForwardedRef<NiconicoViewerHandle
}, [playerId, postToPlayer]) }, [playerId, postToPlayer])
const seek = useCallback ((time: number) => { const seek = useCallback ((time: number) => {
postToPlayer ( postToPlayer ({ eventName: 'seek', sourceConnectorType: 1, playerId, data: { time } })
{ eventName: 'seek', sourceConnectorType: 1, playerId,
data: { time } })
}, [playerId, postToPlayer]) }, [playerId, postToPlayer])
const mute = useCallback (() => { const mute = useCallback (() => {
@@ -197,34 +162,24 @@ export default forwardRef ((props: Props, ref: ForwardedRef<NiconicoViewerHandle
if (data.eventName === 'loadComplete') if (data.eventName === 'loadComplete')
{ {
clearLoadCompleteTimer ()
onLoadComplete?.(data.data.videoInfo) onLoadComplete?.(data.data.videoInfo)
return return
} }
if (data.eventName === 'playerMetadataChange') if (data.eventName === 'playerMetadataChange')
{ {
if (Number.isFinite (data.data.duration) && data.data.duration > 0)
clearLoadCompleteTimer ()
onMetadataChange?.(data.data) onMetadataChange?.(data.data)
return return
} }
if (data.eventName === 'error') if (data.eventName === 'error')
{
clearLoadCompleteTimer ()
console.error ('niconico player error:', data) console.error ('niconico player error:', data)
onError?.(data)
}
} }
addEventListener ('message', onMessage) addEventListener ('message', onMessage)
return () => removeEventListener ('message', onMessage) return () => removeEventListener ('message', onMessage)
}, [clearLoadCompleteTimer, onError, onLoadComplete, onMetadataChange, playerId]) }, [onLoadComplete, onMetadataChange, playerId])
useEffect (() => clearLoadCompleteTimer, [clearLoadCompleteTimer])
useLayoutEffect (() => { useLayoutEffect (() => {
if (!(fullScreen)) if (!(fullScreen))
@@ -279,7 +234,6 @@ export default forwardRef ((props: Props, ref: ForwardedRef<NiconicoViewerHandle
width={width} width={width}
height={height} height={height}
style={margedStyle} style={margedStyle}
onLoad={startLoadCompleteTimer}
allowFullScreen allowFullScreen
allow="autoplay"/>) allow="autoplay"/>)
}) })
-69
ファイルの表示
@@ -1,69 +0,0 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import PostEditForm from '@/components/PostEditForm'
import { buildPost, buildTag } from '@/test/factories'
const postsApi = vi.hoisted (() => ({
updatePost: vi.fn (),
}))
const api = vi.hoisted (() => ({
isApiError: vi.fn (() => false),
}))
const toastApi = vi.hoisted (() => ({
toast: vi.fn (),
}))
vi.mock ('@/lib/posts', () => postsApi)
vi.mock ('@/lib/api', () => api)
vi.mock ('@/components/ui/use-toast', () => toastApi)
vi.mock ('@/components/dialogues/DialogueProvider', () => ({
useDialogue: () => ({
choice: vi.fn (),
}),
}))
describe ('PostEditForm', () => {
it ('submits edited post fields with the current base version', async () => {
const onSave = vi.fn ()
const post = buildPost ({
id: 8,
versionNo: 4,
title: 'old',
tags: [
buildTag ({ name: 'general-tag', category: 'general' }),
buildTag ({ id: 2, name: 'nico-tag', category: 'nico' }),
],
parentPosts: [buildPost ({ id: 2, title: 'parent' })],
})
postsApi.updatePost.mockResolvedValueOnce ({
...post,
versionNo: 5,
title: 'new',
tags: [buildTag ({ name: 'new-tag' })],
})
render (<PostEditForm post={post} onSave={onSave}/>)
const [title, parentIds] = screen.getAllByRole ('textbox')
fireEvent.change (title, { target: { value: 'new' } })
fireEvent.change (parentIds, { target: { value: '3 4' } })
fireEvent.submit (screen.getByRole ('button', { name: '更新' }).closest ('form')!)
await waitFor (() => {
expect (postsApi.updatePost).toHaveBeenCalledWith (
expect.objectContaining ({
id: 8,
title: 'new',
parentPostIds: '3 4',
tags: 'general-tag',
}),
{ baseVersionNo: 4 },
)
})
expect (onSave).toHaveBeenCalledWith (expect.objectContaining ({ versionNo: 5 }))
expect (toastApi.toast).toHaveBeenCalledWith ({ description: '更新しました.' })
})
})
+24 -130
ファイルの表示
@@ -2,23 +2,14 @@ import { useEffect, useState } from 'react'
import PostFormTagsArea from '@/components/PostFormTagsArea' import PostFormTagsArea from '@/components/PostFormTagsArea'
import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField' import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField'
import FieldError from '@/components/common/FieldError' import Label from '@/components/common/Label'
import FormField from '@/components/common/FormField'
import { useDialogue } from '@/components/dialogues/DialogueProvider'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { toast } from '@/components/ui/use-toast' import { apiPut } from '@/lib/api'
import { isApiError } from '@/lib/api'
import { updatePost } from '@/lib/posts'
import { inputClass } from '@/lib/utils'
import { useValidationErrors } from '@/lib/useValidationErrors'
import type { FC, FormEvent } from 'react' import type { FC } from 'react'
import type { Post, Tag } from '@/types' import type { Post, Tag } from '@/types'
type PostFormField =
'parentPostIds' | 'tags' | 'originalCreatedAt'
const tagsToStr = (tags: Tag[]): string => { const tagsToStr = (tags: Tag[]): string => {
const result: Tag[] = [] const result: Tag[] = []
@@ -39,98 +30,25 @@ type Props = { post: Post
onSave: (newPost: Post) => void } onSave: (newPost: Post) => void }
const PostEditForm: FC<Props> = ({ post, onSave }) => { export default (({ post, onSave }: Props) => {
const [disabled, setDisabled] = useState (false)
const { baseErrors, fieldErrors, clearValidationErrors, applyValidationError } =
useValidationErrors<PostFormField> ()
const [originalCreatedBefore, setOriginalCreatedBefore] = const [originalCreatedBefore, setOriginalCreatedBefore] =
useState<string | null> (post.originalCreatedBefore) useState<string | null> (post.originalCreatedBefore)
const [originalCreatedFrom, setOriginalCreatedFrom] = const [originalCreatedFrom, setOriginalCreatedFrom] =
useState<string | null> (post.originalCreatedFrom) useState<string | null> (post.originalCreatedFrom)
const [parentPostIds, setParentPostIds] =
useState ((post.parentPosts ?? []).map (p => p.id).join (' '))
const [tags, setTags] = useState<string> ('')
const [title, setTitle] = useState (post.title) const [title, setTitle] = useState (post.title)
const [tags, setTags] = useState<string> ('')
const dialogue = useDialogue () const handleSubmit = async () => {
const data = await apiPut<Post> (
const update = async (...args: Parameters<typeof updatePost>) => { `/posts/${ post.id }`,
clearValidationErrors () { title, tags, original_created_from: originalCreatedFrom,
original_created_before: originalCreatedBefore },
try { headers: { 'Content-Type': 'multipart/form-data' } })
{
const data = await updatePost (...args)
onSave ({ ...post, onSave ({ ...post,
versionNo: data.versionNo,
title: data.title, title: data.title,
tags: data.tags, tags: data.tags,
parentPosts: data.parentPosts,
childPosts: data.childPosts,
siblingPosts: data.siblingPosts,
originalCreatedFrom: data.originalCreatedFrom, originalCreatedFrom: data.originalCreatedFrom,
originalCreatedBefore: data.originalCreatedBefore } as Post) originalCreatedBefore: data.originalCreatedBefore } as Post)
toast ({ description: '更新しました.' })
}
catch (e)
{
const response = isApiError<{ mergeable?: boolean }> (e) ? e.response : undefined
if (response?.status !== 409)
{
if (applyValidationError (e))
{
toast ({ description: '更新はできなかったよ……' })
return
}
toast ({ title: '失敗……', description: '入力を確認してください.' })
return
}
const action = await dialogue.choice ({
title: '競合が発生しました.',
description: (
<div>
<p></p>
<p>?</p>
</div>),
choices: [...(response?.data?.mergeable ? [{ value: 'merge', label: '差分をマージ' }] : []),
{ value: 'overwrite', label: '強制上書き', variant: 'danger' }] })
if (action === 'merge')
{
// TODO: 差分 UI
await update ({ id: post.id, title, tags, parentPostIds,
originalCreatedFrom, originalCreatedBefore },
{ baseVersionNo: post.versionNo, merge: true })
return
}
if (action === 'overwrite')
{
await update ({ id: post.id, title, tags, parentPostIds,
originalCreatedFrom, originalCreatedBefore },
{ baseVersionNo: post.versionNo, force: true })
return
}
}
}
const handleSubmit = async (e: FormEvent) => {
e.preventDefault ()
setDisabled (true)
try
{
await update ({ id: post.id, title, tags, parentPostIds,
originalCreatedFrom, originalCreatedBefore },
{ baseVersionNo: post.versionNo })
}
finally
{
setDisabled (false)
}
} }
useEffect (() => { useEffect (() => {
@@ -138,54 +56,30 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
}, [post]) }, [post])
return ( return (
<form onSubmit={handleSubmit} className="max-w-xl pt-2 space-y-4"> <div className="max-w-xl pt-2 space-y-4">
<FieldError messages={baseErrors}/>
{/* タイトル */} {/* タイトル */}
<FormField label="タイトル"> <div>
{({ invalid }) => ( <Label></Label>
<input <input type="text"
type="text" className="w-full border rounded p-2"
disabled={disabled}
className={inputClass (invalid)}
value={title ?? ''} value={title ?? ''}
onChange={ev => setTitle (ev.target.value)}/>)} onChange={ev => setTitle (ev.target.value)}/>
</FormField> </div>
{/* 親投稿 */}
<FormField label="親投稿" messages={fieldErrors.parentPostIds}>
{({ describedBy, invalid }) => (
<input
type="text"
disabled={disabled}
value={parentPostIds}
onChange={e => setParentPostIds (e.target.value)}
aria-describedby={describedBy}
aria-invalid={invalid}
className={inputClass (invalid)}/>)}
</FormField>
{/* タグ */} {/* タグ */}
<PostFormTagsArea <PostFormTagsArea tags={tags} setTags={setTags}/>
disabled={disabled}
tags={tags}
setTags={setTags}
errors={fieldErrors.tags}/>
{/* オリジナルの作成日時 */} {/* オリジナルの作成日時 */}
<PostOriginalCreatedTimeField <PostOriginalCreatedTimeField
disabled={disabled}
originalCreatedFrom={originalCreatedFrom} originalCreatedFrom={originalCreatedFrom}
setOriginalCreatedFrom={setOriginalCreatedFrom} setOriginalCreatedFrom={setOriginalCreatedFrom}
originalCreatedBefore={originalCreatedBefore} originalCreatedBefore={originalCreatedBefore}
setOriginalCreatedBefore={setOriginalCreatedBefore} setOriginalCreatedBefore={setOriginalCreatedBefore}/>
errors={fieldErrors.originalCreatedAt}/>
{/* 送信 */} {/* 送信 */}
<Button type="submit" disabled={disabled}> <Button onClick={handleSubmit}
className="px-4 py-2 bg-blue-600 text-white rounded disabled:bg-gray-400">
</Button> </Button>
</form>) </div>)
} }) satisfies FC<Props>
export default PostEditForm
-128
ファイルの表示
@@ -1,128 +0,0 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import PostEmbed from '@/components/PostEmbed'
import { buildPost } from '@/test/factories'
const dialogue = vi.hoisted (() => ({
confirm: vi.fn (),
}))
const nicoViewer = vi.hoisted (() => ({
props: vi.fn (),
}))
vi.mock ('@/components/dialogues/DialogueProvider', () => ({
useDialogue: () => dialogue,
}))
vi.mock ('@/components/NicoViewer', () => ({
default: (props: { id: string }) => {
nicoViewer.props (props)
return <div>Nico:{props.id}</div>
},
}))
vi.mock ('react-youtube', () => ({
default: ({ videoId }: { videoId: string }) => <div>YouTube:{videoId}</div>,
}))
describe ('PostEmbed', () => {
beforeEach (() => {
vi.clearAllMocks ()
})
it ('embeds nicovideo watch URLs', () => {
render (<PostEmbed post={buildPost ({ url: 'https://www.nicovideo.jp/watch/sm12345' })}/>)
expect (screen.getByText ('Nico:sm12345')).toBeInTheDocument ()
})
it ('reports niconico metadata as milliseconds', () => {
const onVideoReady = vi.fn ()
const onPlaybackChange = vi.fn ()
render (
<PostEmbed
post={buildPost ({ url: 'https://www.nicovideo.jp/watch/sm12345' })}
onVideoReady={onVideoReady}
onPlaybackChange={onPlaybackChange}/>,
)
nicoViewer.props.mock.calls[0][0].onMetadataChange ({
currentTime: 7_000,
duration: 120_000,
isVideoMetaDataLoaded: true,
maximumBuffered: 30,
muted: false,
showComment: true,
volume: 1,
})
expect (onVideoReady).toHaveBeenCalledWith (120_000)
expect (onPlaybackChange).toHaveBeenCalledWith (7_000)
})
it ('reports niconico video readiness only once', () => {
const onVideoReady = vi.fn ()
render (
<PostEmbed
post={buildPost ({ url: 'https://www.nicovideo.jp/watch/sm12345' })}
onVideoReady={onVideoReady}/>,
)
nicoViewer.props.mock.calls[0][0].onLoadComplete ({
title: '動画',
videoId: 'sm12345',
lengthInSeconds: 120,
thumbnailUrl: 'https://example.com/thumb.jpg',
description: '',
viewCount: 1,
commentCount: 2,
mylistCount: 3,
postedAt: '2026-01-02T03:04:05.000Z',
watchId: 12345,
})
nicoViewer.props.mock.calls[0][0].onMetadataChange ({
currentTime: 7_000,
duration: 120_000,
isVideoMetaDataLoaded: true,
maximumBuffered: 30,
muted: false,
showComment: true,
volume: 1,
})
expect (onVideoReady).toHaveBeenCalledTimes (1)
expect (onVideoReady).toHaveBeenCalledWith (120_000)
})
it ('embeds x/twitter status URLs', () => {
render (<PostEmbed post={buildPost ({ url: 'https://x.com/someone/status/12345' })}/>)
expect (screen.getByRole ('link', { name: '@someone' })).toBeInTheDocument ()
})
it ('embeds youtube watch URLs', () => {
render (<PostEmbed post={buildPost ({ url: 'https://www.youtube.com/watch?v=abc123' })}/>)
expect (screen.getByText ('YouTube:abc123')).toBeInTheDocument ()
})
it ('asks before framing unknown external pages', async () => {
dialogue.confirm.mockResolvedValueOnce (true)
render (
<PostEmbed
post={buildPost ({ url: 'https://example.com/page', title: 'external' })}/>,
)
fireEvent.click (screen.getByRole ('link', { name: '外部ページを表示' }))
await waitFor (() => {
expect (dialogue.confirm).toHaveBeenCalled ()
})
expect (await screen.findByTitle ('external')).toHaveAttribute (
'src',
'https://example.com/page',
)
})
})
+15 -124
ファイルの表示
@@ -1,121 +1,21 @@
import { useCallback, useEffect, useRef, useState } from 'react' import { useState } from 'react'
import YoutubeEmbed from 'react-youtube' import YoutubeEmbed from 'react-youtube'
import NicoViewer from '@/components/NicoViewer' import NicoViewer from '@/components/NicoViewer'
import TwitterEmbed from '@/components/TwitterEmbed' import TwitterEmbed from '@/components/TwitterEmbed'
import { useDialogue } from '@/components/dialogues/DialogueProvider'
import type { FC, RefObject } from 'react' import type { FC, RefObject } from 'react'
import type { NiconicoMetadata, NiconicoVideoInfo, NiconicoViewerHandle, Post } from '@/types' import type { NiconicoMetadata, NiconicoVideoInfo, NiconicoViewerHandle, Post } from '@/types'
import type { YouTubePlayer } from 'react-youtube'
type YouTubeEvent<T = unknown> = {
data: T
target: YouTubePlayer }
type Props = { type Props = {
ref?: RefObject<NiconicoViewerHandle | null> ref?: RefObject<NiconicoViewerHandle | null>
post: Post post: Post
onLoadComplete?: (info: NiconicoVideoInfo) => void onLoadComplete?: (info: NiconicoVideoInfo) => void
onMetadataChange?: (meta: NiconicoMetadata) => void onMetadataChange?: (meta: NiconicoMetadata) => void }
onVideoReady?: (durationMs: number) => void
onPlaybackChange?: (currentTimeMs: number) => number | void
onError?: (data: unknown) => void }
const PostEmbed: FC<Props> = ({ export default (({ ref, post, onLoadComplete, onMetadataChange }: Props) => {
ref,
post,
onLoadComplete,
onMetadataChange,
onVideoReady,
onPlaybackChange,
onError,
}) => {
const dialogue = useDialogue ()
const [framed, setFramed] = useState (false)
const [youtubePlayer, setYoutubePlayer] = useState<YouTubePlayer | null> (null)
const niconicoVideoReadyRef = useRef (false)
const notifyNiconicoVideoReady = useCallback ((durationMs: number) => {
if (niconicoVideoReadyRef.current
|| !(Number.isFinite (durationMs))
|| durationMs <= 0)
return
niconicoVideoReadyRef.current = true
onVideoReady?.(durationMs)
}, [onVideoReady])
const reportYoutubePlayback = useCallback (async (player: YouTubePlayer) => {
const currentTime = await player.getCurrentTime ()
const currentTimeMs = currentTime * 1_000
const targetTimeMs = onPlaybackChange?.(currentTimeMs)
if (typeof targetTimeMs !== 'number')
return
if (Math.abs (currentTimeMs - targetTimeMs) > 5_000)
await player.seekTo (targetTimeMs / 1_000, true)
}, [onPlaybackChange])
const handleYoutubeReady = async (event: YouTubeEvent) => {
setYoutubePlayer (event.target)
try
{
await event.target.playVideo ()
const duration = await event.target.getDuration ()
const durationMs = duration * 1_000
onVideoReady?.(durationMs)
if (!(Number.isFinite (durationMs)) || durationMs <= 0)
return
await reportYoutubePlayback (event.target)
}
catch (error)
{
onError?.({ platform: 'youtube', error })
}
}
const handleYoutubeStateChange = (event: YouTubeEvent<number>) => {
void reportYoutubePlayback (event.target)
}
const handleYoutubeError = (event: YouTubeEvent<number>) => {
onError?.({ platform: 'youtube', code: event.data })
}
const handleNiconicoLoadComplete = (info: NiconicoVideoInfo) => {
notifyNiconicoVideoReady (info.lengthInSeconds * 1_000)
onLoadComplete?.(info)
}
const handleNiconicoMetadataChange = (meta: NiconicoMetadata) => {
notifyNiconicoVideoReady (meta.duration)
onPlaybackChange?.(meta.currentTime)
onMetadataChange?.(meta)
}
useEffect (() => {
niconicoVideoReadyRef.current = false
}, [post.url])
useEffect (() => {
if (!(youtubePlayer) || !(onPlaybackChange))
return
const timer = setInterval (
() => void reportYoutubePlayback (youtubePlayer),
1_000)
return () => clearInterval (timer)
}, [onPlaybackChange, reportYoutubePlayback, youtubePlayer])
const url = new URL (post.url) const url = new URL (post.url)
switch (url.hostname.split ('.').slice (-2).join ('.')) switch (url.hostname.split ('.').slice (-2).join ('.'))
@@ -134,15 +34,14 @@ const PostEmbed: FC<Props> = ({
id={videoId} id={videoId}
width={640} width={640}
height={360} height={360}
onLoadComplete={handleNiconicoLoadComplete} onLoadComplete={onLoadComplete}
onMetadataChange={handleNiconicoMetadataChange} onMetadataChange={onMetadataChange}/>)
onError={onError}/>)
} }
case 'twitter.com': case 'twitter.com':
case 'x.com': case 'x.com':
{ {
const mUserId = url.pathname.match (/(?<=\/)[^/]+?(?=\/|$|\?)/) const mUserId = url.pathname.match (/(?<=\/)[^\/]+?(?=\/|$|\?)/)
const mStatusId = url.pathname.match (/(?<=\/status\/)\d+?(?=\/|$|\?)/) const mStatusId = url.pathname.match (/(?<=\/status\/)\d+?(?=\/|$|\?)/)
if (!(mUserId) || !(mStatusId)) if (!(mUserId) || !(mStatusId))
break break
@@ -166,13 +65,12 @@ const PostEmbed: FC<Props> = ({
mute: 0, mute: 0,
loop: 1, loop: 1,
width: '640', width: '640',
height: '360' } }} height: '360' } }}/>)
onReady={handleYoutubeReady}
onStateChange={handleYoutubeStateChange}
onError={handleYoutubeError}/>)
} }
} }
const [framed, setFramed] = useState (false)
return ( return (
<> <>
{framed {framed
@@ -184,22 +82,15 @@ const PostEmbed: FC<Props> = ({
height={360}/>) height={360}/>)
: ( : (
<div> <div>
<a href="#" onClick={async e => { <a href="#" onClick={e => {
e.preventDefault () e.preventDefault ()
setFramed (confirm ('未確認の外部ページを表示します。\n'
setFramed (await dialogue.confirm ({ + '悪意のあるスクリプトが実行される可能性があります。\n'
title: '未確認の外部ページを表示します', + '表示しますか?'))
description: ( return
<div>
<p></p>
<p>?</p>
</div>),
confirmText: '表示' }))
}}> }}>
</a> </a>
</div>)} </div>)}
</>) </>)
} }) satisfies FC<Props>
export default PostEmbed
-34
ファイルの表示
@@ -1,34 +0,0 @@
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import PostFormTagsArea from '@/components/PostFormTagsArea'
import { buildTag } from '@/test/factories'
import { renderWithProviders } from '@/test/render'
const api = vi.hoisted (() => ({
apiGet: vi.fn (),
}))
vi.mock ('@/lib/api', () => api)
describe ('PostFormTagsArea', () => {
it ('updates text and fetches autocomplete for the selected token', async () => {
const setTags = vi.fn ()
api.apiGet.mockResolvedValueOnce ([buildTag ({ name: '虹夏', postCount: 3 })])
renderWithProviders (<PostFormTagsArea tags="虹" setTags={setTags}/>)
const textarea = screen.getByRole ('textbox')
fireEvent.focus (textarea)
fireEvent.select (textarea, { target: { selectionStart: 1, selectionEnd: 1 } })
fireEvent.change (textarea, { target: { value: '虹夏' } })
await waitFor (() => {
expect (api.apiGet).toHaveBeenCalledWith (
'/tags/autocomplete',
{ params: { q: '虹', nico: '0' } },
)
})
expect (setTags).toHaveBeenCalledWith ('虹夏')
})
})
+9 -17
ファイルの表示
@@ -3,11 +3,11 @@
import { useRef, useState } from 'react' import { useRef, useState } from 'react'
import TagSearchBox from '@/components/TagSearchBox' import TagSearchBox from '@/components/TagSearchBox'
import FormField from '@/components/common/FormField' import Label from '@/components/common/Label'
import TextArea from '@/components/common/TextArea' import TextArea from '@/components/common/TextArea'
import { apiGet } from '@/lib/api' import { apiGet } from '@/lib/api'
import type { ComponentPropsWithoutRef, FC, SyntheticEvent } from 'react' import type { FC, SyntheticEvent } from 'react'
import type { Tag } from '@/types' import type { Tag } from '@/types'
@@ -31,13 +31,12 @@ const replaceToken = (value: string, start: number, end: number, text: string) =
`${ value.slice (0, start) }${ text }${ value.slice (end) }` `${ value.slice (0, start) }${ text }${ value.slice (end) }`
type Props = Omit<ComponentPropsWithoutRef<'textarea'>, 'value' | 'onChange' | 'onBlur'> & { type Props = {
tags: string tags: string
setTags: (tags: string) => void setTags: (tags: string) => void }
errors?: string[] }
const PostFormTagsArea: FC<Props> = ({ tags, setTags, errors, ...rest }) => { export default (({ tags, setTags }: Props) => {
const ref = useRef<HTMLTextAreaElement> (null) const ref = useRef<HTMLTextAreaElement> (null)
const [bounds, setBounds] = useState<{ start: number; end: number }> ({ start: 0, end: 0 }) const [bounds, setBounds] = useState<{ start: number; end: number }> ({ start: 0, end: 0 })
@@ -74,15 +73,11 @@ const PostFormTagsArea: FC<Props> = ({ tags, setTags, errors, ...rest }) => {
} }
return ( return (
<FormField className="relative w-full" label="タグ" messages={errors}> <div className="relative w-full">
{({ describedBy, invalid }) => ( <Label></Label>
<>
<TextArea <TextArea
{...rest}
ref={ref} ref={ref}
value={tags} value={tags}
aria-describedby={describedBy}
invalid={invalid}
onChange={ev => setTags (ev.target.value)} onChange={ev => setTags (ev.target.value)}
onSelect={async (ev: SyntheticEvent<HTMLTextAreaElement>) => { onSelect={async (ev: SyntheticEvent<HTMLTextAreaElement>) => {
const pos = (ev.target as HTMLTextAreaElement).selectionStart const pos = (ev.target as HTMLTextAreaElement).selectionStart
@@ -100,8 +95,5 @@ const PostFormTagsArea: FC<Props> = ({ tags, setTags, errors, ...rest }) => {
: [] as Tag[]} : [] as Tag[]}
activeIndex={-1} activeIndex={-1}
onSelect={handleTagSelect}/>)} onSelect={handleTagSelect}/>)}
</>)} </div>)
</FormField>) }) satisfies FC<Props>
}
export default PostFormTagsArea

変更されたファイルが多すぎるため,一部のファイルは表示されません さらに表示