Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 316809bf1c | |||
| 32cb6f1d33 | |||
| 73152f2934 | |||
| 18cae51182 | |||
| a07cab97f3 | |||
| bbf14e3067 | |||
| 2de7e13a8a | |||
| d3f2b009bc | |||
| e03cc01109 | |||
| b47cdc7ad7 | |||
| 52aa1615b6 | |||
| dceed1caa1 | |||
| 5002859fc8 | |||
| fcd3b87b2a | |||
| 0ff7fdf78a | |||
| b2c3e02ccc | |||
| c112576b11 | |||
| 6235b293f0 | |||
| 43cd38a216 | |||
| 8ff1819d5a | |||
| bde7d33949 | |||
| 5c7580d571 | |||
| 48f823a7c8 | |||
| bd11e37fd3 | |||
| e72ec608f4 | |||
| a3914fb22a | |||
| c36b2c8a1b | |||
| e021423904 | |||
| 7b15cb2c5a | |||
| 2adff3966a |
@@ -0,0 +1,35 @@
|
|||||||
|
## 背景
|
||||||
|
|
||||||
|
なぜ必要か。
|
||||||
|
|
||||||
|
## 対象範囲
|
||||||
|
|
||||||
|
- 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 を読んで実装してください。
|
||||||
|
不明点があれば、実装前に調査結果と選択肢を提示してください。
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
# 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 preview
|
||||||
|
```
|
||||||
|
|
||||||
|
`npm run build` runs `tsc -b && vite build`, then `postbuild` runs `node scripts/generate-sitemap.js`.
|
||||||
|
|
||||||
|
Do not write or report `npm test` as a repository command unless a `test` script is added to `frontend/package.json`.
|
||||||
|
|
||||||
|
## 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: do not use `%w` or `%i`.
|
||||||
|
- TypeScript and Python: use GNU-style spacing before parentheses where syntactically valid.
|
||||||
|
- 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.
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
## 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` and `npm run lint`.
|
||||||
|
- 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.
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
# 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.
|
||||||
|
- Do not use `%w` or `%i` in new Ruby code.
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
## 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
-2
@@ -50,8 +50,6 @@ group :development, :test do
|
|||||||
gem 'factory_bot_rails'
|
gem 'factory_bot_rails'
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
gem "mysql2", "~> 0.5.6"
|
gem "mysql2", "~> 0.5.6"
|
||||||
|
|
||||||
gem "image_processing", "~> 1.14"
|
gem "image_processing", "~> 1.14"
|
||||||
@@ -69,3 +67,5 @@ gem 'whenever', require: false
|
|||||||
gem 'discard'
|
gem 'discard'
|
||||||
|
|
||||||
gem "rspec-rails", "~> 8.0", :groups => [:development, :test]
|
gem "rspec-rails", "~> 8.0", :groups => [:development, :test]
|
||||||
|
|
||||||
|
gem 'aws-sdk-s3', require: false
|
||||||
|
|||||||
@@ -73,6 +73,25 @@ GEM
|
|||||||
tzinfo (~> 2.0, >= 2.0.5)
|
tzinfo (~> 2.0, >= 2.0.5)
|
||||||
uri (>= 0.13.1)
|
uri (>= 0.13.1)
|
||||||
ast (2.4.3)
|
ast (2.4.3)
|
||||||
|
aws-eventstream (1.4.0)
|
||||||
|
aws-partitions (1.1231.0)
|
||||||
|
aws-sdk-core (3.244.0)
|
||||||
|
aws-eventstream (~> 1, >= 1.3.0)
|
||||||
|
aws-partitions (~> 1, >= 1.992.0)
|
||||||
|
aws-sigv4 (~> 1.9)
|
||||||
|
base64
|
||||||
|
bigdecimal
|
||||||
|
jmespath (~> 1, >= 1.6.1)
|
||||||
|
logger
|
||||||
|
aws-sdk-kms (1.123.0)
|
||||||
|
aws-sdk-core (~> 3, >= 3.244.0)
|
||||||
|
aws-sigv4 (~> 1.5)
|
||||||
|
aws-sdk-s3 (1.217.0)
|
||||||
|
aws-sdk-core (~> 3, >= 3.244.0)
|
||||||
|
aws-sdk-kms (~> 1)
|
||||||
|
aws-sigv4 (~> 1.5)
|
||||||
|
aws-sigv4 (1.12.1)
|
||||||
|
aws-eventstream (~> 1, >= 1.0.2)
|
||||||
base64 (0.2.0)
|
base64 (0.2.0)
|
||||||
bcrypt_pbkdf (1.1.1)
|
bcrypt_pbkdf (1.1.1)
|
||||||
bcrypt_pbkdf (1.1.1-arm64-darwin)
|
bcrypt_pbkdf (1.1.1-arm64-darwin)
|
||||||
@@ -157,6 +176,7 @@ GEM
|
|||||||
pp (>= 0.6.0)
|
pp (>= 0.6.0)
|
||||||
rdoc (>= 4.0.0)
|
rdoc (>= 4.0.0)
|
||||||
reline (>= 0.4.2)
|
reline (>= 0.4.2)
|
||||||
|
jmespath (1.6.2)
|
||||||
json (2.12.0)
|
json (2.12.0)
|
||||||
jwt (2.10.1)
|
jwt (2.10.1)
|
||||||
base64
|
base64
|
||||||
@@ -441,6 +461,7 @@ PLATFORMS
|
|||||||
x86_64-linux-musl
|
x86_64-linux-musl
|
||||||
|
|
||||||
DEPENDENCIES
|
DEPENDENCIES
|
||||||
|
aws-sdk-s3
|
||||||
bootsnap
|
bootsnap
|
||||||
brakeman
|
brakeman
|
||||||
diff-lcs
|
diff-lcs
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
class ApplicationController < ActionController::API
|
class ApplicationController < ActionController::API
|
||||||
|
before_action :reject_banned_ip_address!
|
||||||
before_action :authenticate_user
|
before_action :authenticate_user
|
||||||
|
before_action :reject_banned_user!
|
||||||
|
|
||||||
def current_user
|
def current_user = @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
|
||||||
|
|
||||||
@@ -22,4 +24,17 @@ class ApplicationController < ActionController::API
|
|||||||
s.in?(['', '1', 'true', 'on', 'yes'])
|
s.in?(['', '1', 'true', 'on', 'yes'])
|
||||||
end
|
end
|
||||||
end
|
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
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
class MaterialsController < ApplicationController
|
||||||
|
def index
|
||||||
|
page = (params[:page].presence || 1).to_i
|
||||||
|
limit = (params[:limit].presence || 20).to_i
|
||||||
|
|
||||||
|
page = 1 if page < 1
|
||||||
|
limit = 1 if limit < 1
|
||||||
|
|
||||||
|
offset = (page - 1) * limit
|
||||||
|
|
||||||
|
tag_id = params[:tag_id].presence
|
||||||
|
parent_id = params[:parent_id].presence
|
||||||
|
|
||||||
|
q = Material.includes(:tag, :created_by_user).with_attached_file
|
||||||
|
q = q.where(tag_id:) if tag_id
|
||||||
|
q = q.where(parent_id:) if parent_id
|
||||||
|
|
||||||
|
count = q.count
|
||||||
|
materials = q.order(created_at: :desc, id: :desc).limit(limit).offset(offset)
|
||||||
|
|
||||||
|
render json: { materials: MaterialRepr.many(materials, host: request.base_url), count: count }
|
||||||
|
end
|
||||||
|
|
||||||
|
def show
|
||||||
|
material =
|
||||||
|
Material
|
||||||
|
.includes(:tag)
|
||||||
|
.with_attached_file
|
||||||
|
.find_by(id: params[:id])
|
||||||
|
return head :not_found unless material
|
||||||
|
|
||||||
|
wiki_page_body = material.tag.tag_name.wiki_page&.current_revision&.body
|
||||||
|
|
||||||
|
render json: MaterialRepr.base(material, host: request.base_url).merge(wiki_page_body:)
|
||||||
|
end
|
||||||
|
|
||||||
|
def create
|
||||||
|
return head :unauthorized unless current_user
|
||||||
|
|
||||||
|
tag_name_raw = params[:tag].to_s.strip
|
||||||
|
file = params[:file]
|
||||||
|
url = params[:url].to_s.strip.presence
|
||||||
|
return head :bad_request if tag_name_raw.blank? || (file.blank? && url.blank?)
|
||||||
|
|
||||||
|
tag_name = TagName.find_undiscard_or_create_by!(name: tag_name_raw)
|
||||||
|
tag = tag_name.tag
|
||||||
|
tag = Tag.create!(tag_name:, category: :material) unless tag
|
||||||
|
|
||||||
|
material = Material.new(tag:, url:,
|
||||||
|
created_by_user: current_user,
|
||||||
|
updated_by_user: current_user)
|
||||||
|
material.file.attach(file)
|
||||||
|
|
||||||
|
if material.save
|
||||||
|
render json: MaterialRepr.base(material, host: request.base_url), status: :created
|
||||||
|
else
|
||||||
|
render json: { errors: material.errors.full_messages }, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def update
|
||||||
|
return head :unauthorized unless current_user
|
||||||
|
return head :forbidden unless current_user.gte_member?
|
||||||
|
|
||||||
|
material = Material.with_attached_file.find_by(id: params[:id])
|
||||||
|
return head :not_found unless material
|
||||||
|
|
||||||
|
tag_name_raw = params[:tag].to_s.strip
|
||||||
|
file = params[:file]
|
||||||
|
url = params[:url].to_s.strip.presence
|
||||||
|
return head :bad_request if tag_name_raw.blank? || (file.blank? && url.blank?)
|
||||||
|
|
||||||
|
tag_name = TagName.find_undiscard_or_create_by!(name: tag_name_raw)
|
||||||
|
tag = tag_name.tag
|
||||||
|
tag = Tag.create!(tag_name:, category: :material) unless tag
|
||||||
|
|
||||||
|
material.update!(tag:, url:, updated_by_user: current_user)
|
||||||
|
if file
|
||||||
|
material.file.attach(file)
|
||||||
|
else
|
||||||
|
material.file.purge
|
||||||
|
end
|
||||||
|
|
||||||
|
if material.save
|
||||||
|
render json: MaterialRepr.base(material, host: request.base_url)
|
||||||
|
else
|
||||||
|
render json: { errors: material.errors.full_messages }, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def destroy
|
||||||
|
return head :unauthorized unless current_user
|
||||||
|
return head :forbidden unless current_user.gte_member?
|
||||||
|
|
||||||
|
material = Material.find_by(id: params[:id])
|
||||||
|
return head :not_found unless material
|
||||||
|
|
||||||
|
material.discard
|
||||||
|
head :no_content
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -30,14 +30,21 @@ class NicoTagsController < ApplicationController
|
|||||||
id = params[:id].to_i
|
id = params[:id].to_i
|
||||||
|
|
||||||
tag = Tag.find(id)
|
tag = Tag.find(id)
|
||||||
return head :bad_request if tag.category != '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 = Tag.normalise_tags(linked_tag_names, with_tagme: false)
|
linked_tags = Tag.normalise_tags!(linked_tag_names, with_tagme: false,
|
||||||
return head :bad_request if linked_tags.any? { |t| t.category == 'nico' }
|
with_no_deerjikist: false)
|
||||||
|
return head :bad_request if linked_tags.any? { |t| t.nico? }
|
||||||
|
|
||||||
tag.linked_tags = linked_tags
|
ApplicationRecord.transaction do
|
||||||
tag.save!
|
TagVersioning.record_tag_snapshots!(linked_tags, created_by_user: current_user)
|
||||||
|
|
||||||
|
tag.linked_tags = linked_tags
|
||||||
|
tag.save!
|
||||||
|
|
||||||
|
NicoTagVersionRecorder.record!(tag:, event_type: :update, created_by_user: current_user)
|
||||||
|
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
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
class PostVersionsController < ApplicationController
|
||||||
|
def index
|
||||||
|
post_id = params[:post].presence
|
||||||
|
tag_id = params[:tag].presence
|
||||||
|
page = (params[:page].presence || 1).to_i
|
||||||
|
limit = (params[:limit].presence || 20).to_i
|
||||||
|
|
||||||
|
page = 1 if page < 1
|
||||||
|
limit = 1 if limit < 1
|
||||||
|
|
||||||
|
offset = (page - 1) * limit
|
||||||
|
|
||||||
|
tag_name =
|
||||||
|
if tag_id
|
||||||
|
TagName.joins(:tag).find_by(tag: { id: tag_id })
|
||||||
|
end
|
||||||
|
return render json: { versions: [], count: 0 } if tag_id && tag_name.blank?
|
||||||
|
|
||||||
|
q = PostVersion.joins(<<~SQL.squish)
|
||||||
|
LEFT JOIN
|
||||||
|
post_versions prev
|
||||||
|
ON
|
||||||
|
prev.post_id = post_versions.post_id
|
||||||
|
AND prev.version_no = post_versions.version_no - 1
|
||||||
|
SQL
|
||||||
|
.select('post_versions.*', 'prev.title AS prev_title', 'prev.url AS prev_url',
|
||||||
|
'prev.thumbnail_base AS prev_thumbnail_base', 'prev.tags AS prev_tags',
|
||||||
|
'prev.original_created_from AS prev_original_created_from',
|
||||||
|
'prev.original_created_before AS prev_original_created_before')
|
||||||
|
q = q.where('post_versions.post_id = ?', post_id) if post_id
|
||||||
|
if tag_name
|
||||||
|
escaped = ActiveRecord::Base.sanitize_sql_like(tag_name.name)
|
||||||
|
q = q.where(("CONCAT(' ', post_versions.tags, ' ') LIKE :kw " +
|
||||||
|
"OR CONCAT(' ', prev.tags, ' ') LIKE :kw"),
|
||||||
|
kw: "% #{ escaped } %")
|
||||||
|
end
|
||||||
|
|
||||||
|
count = q.except(:select, :order, :limit, :offset).count
|
||||||
|
|
||||||
|
versions = q.order(Arel.sql('post_versions.created_at DESC, post_versions.id DESC'))
|
||||||
|
.limit(limit)
|
||||||
|
.offset(offset)
|
||||||
|
|
||||||
|
render json: { versions: serialise_versions(versions), count: }
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def serialise_versions rows
|
||||||
|
user_ids = rows.map(&:created_by_user_id).compact.uniq
|
||||||
|
users_by_id = User.where(id: user_ids).pluck(:id, :name).to_h
|
||||||
|
|
||||||
|
rows.map do |row|
|
||||||
|
cur_tags = split_tags(row.tags)
|
||||||
|
prev_tags = split_tags(row.attributes['prev_tags'])
|
||||||
|
|
||||||
|
{
|
||||||
|
post_id: row.post_id,
|
||||||
|
version_no: row.version_no,
|
||||||
|
event_type: row.event_type,
|
||||||
|
title: {
|
||||||
|
current: row.title,
|
||||||
|
prev: row.attributes['prev_title']
|
||||||
|
},
|
||||||
|
url: {
|
||||||
|
current: row.url,
|
||||||
|
prev: row.attributes['prev_url']
|
||||||
|
},
|
||||||
|
thumbnail: {
|
||||||
|
current: nil,
|
||||||
|
prev: nil
|
||||||
|
},
|
||||||
|
thumbnail_base: {
|
||||||
|
current: row.thumbnail_base,
|
||||||
|
prev: row.attributes['prev_thumbnail_base']
|
||||||
|
},
|
||||||
|
tags: build_version_tags(cur_tags, prev_tags),
|
||||||
|
original_created_from: {
|
||||||
|
current: row.original_created_from&.iso8601,
|
||||||
|
prev: row.attributes['prev_original_created_from']&.iso8601
|
||||||
|
},
|
||||||
|
original_created_before: {
|
||||||
|
current: row.original_created_before&.iso8601,
|
||||||
|
prev: row.attributes['prev_original_created_before']&.iso8601
|
||||||
|
},
|
||||||
|
created_at: row.created_at.iso8601,
|
||||||
|
created_by_user:
|
||||||
|
if row.created_by_user_id
|
||||||
|
{
|
||||||
|
id: row.created_by_user_id,
|
||||||
|
name: users_by_id[row.created_by_user_id]
|
||||||
|
}
|
||||||
|
end
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_version_tags(cur_tags, prev_tags)
|
||||||
|
(cur_tags | prev_tags).map do |name|
|
||||||
|
type =
|
||||||
|
if cur_tags.include?(name) && prev_tags.include?(name)
|
||||||
|
'context'
|
||||||
|
elsif cur_tags.include?(name)
|
||||||
|
'added'
|
||||||
|
else
|
||||||
|
'removed'
|
||||||
|
end
|
||||||
|
|
||||||
|
{
|
||||||
|
name:,
|
||||||
|
type:
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def split_tags(tags)
|
||||||
|
tags.to_s.split(/\s+/).reject(&:blank?)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -44,7 +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(tags: { tag_name: :wiki_page })
|
.preload(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
|
||||||
@@ -95,7 +95,7 @@ class PostsController < ApplicationController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def random
|
def random
|
||||||
post = filtered_posts.preload(tags: { tag_name: :wiki_page })
|
post = filtered_posts.preload(tags: [:deerjikists, :materials, { tag_name: :wiki_page }])
|
||||||
.order('RAND()')
|
.order('RAND()')
|
||||||
.first
|
.first
|
||||||
return head :not_found unless post
|
return head :not_found unless post
|
||||||
@@ -104,12 +104,12 @@ class PostsController < ApplicationController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def show
|
def show
|
||||||
post = Post.includes(tags: { tag_name: :wiki_page }).find_by(id: params[:id])
|
post = Post.includes(tags: [:deerjikists, :materials, { tag_name: :wiki_page }]).find_by(id: params[:id])
|
||||||
return head :not_found unless post
|
return head :not_found unless post
|
||||||
|
|
||||||
render json: PostRepr.base(post, current_user)
|
render json: PostRepr.base(post, current_user)
|
||||||
.merge(tags: build_tag_tree_for(post.tags),
|
.merge(tags: build_tag_tree_for(post.tags),
|
||||||
related: post.related(limit: 20))
|
related: PostRepr.many(post.related(limit: 20)))
|
||||||
end
|
end
|
||||||
|
|
||||||
def create
|
def create
|
||||||
@@ -123,23 +123,36 @@ 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)
|
post.thumbnail.attach(thumbnail) if thumbnail.present?
|
||||||
if post.save
|
|
||||||
post.resized_thumbnail!
|
ApplicationRecord.transaction do
|
||||||
tags = Tag.normalise_tags(tag_names)
|
post.save!
|
||||||
|
|
||||||
|
tags = Tag.normalise_tags!(tag_names)
|
||||||
|
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)
|
||||||
|
|
||||||
post.reload
|
sync_parent_posts!(post, parent_post_ids)
|
||||||
render json: PostRepr.base(post), status: :created
|
|
||||||
else
|
post.resized_thumbnail!
|
||||||
render json: { errors: post.errors.full_messages }, status: :unprocessable_entity
|
|
||||||
|
PostVersionRecorder.record!(post:, event_type: :create, created_by_user: current_user)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
post.reload
|
||||||
|
render json: PostRepr.base(post), status: :created
|
||||||
rescue Tag::NicoTagNormalisationError
|
rescue Tag::NicoTagNormalisationError
|
||||||
head :bad_request
|
head :bad_request
|
||||||
|
rescue ArgumentError => e
|
||||||
|
render json: { errors: [e.message] }, status: :unprocessable_entity
|
||||||
|
rescue ActiveRecord::RecordInvalid => e
|
||||||
|
render json: { errors: e.record.errors.full_messages }, status: :unprocessable_entity
|
||||||
end
|
end
|
||||||
|
|
||||||
def viewed
|
def viewed
|
||||||
@@ -160,27 +173,76 @@ 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 head :bad_request if force && merge
|
||||||
|
|
||||||
|
base_version_no = parse_base_version_no
|
||||||
|
return head :bad_request 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 = Post.find(params[:id].to_i)
|
post = nil
|
||||||
if post.update(title:, original_created_from:, original_created_before:)
|
conflict_json = nil
|
||||||
tags = post.tags.where(category: 'nico').to_a +
|
|
||||||
Tag.normalise_tags(tag_names, with_tagme: false)
|
|
||||||
tags = Tag.expand_parent_tags(tags)
|
|
||||||
sync_post_tags!(post, tags)
|
|
||||||
|
|
||||||
post.reload
|
ApplicationRecord.transaction do
|
||||||
json = post.as_json
|
post = Post.lock.find(params[:id].to_i)
|
||||||
json['tags'] = build_tag_tree_for(post.tags)
|
|
||||||
render json:, status: :ok
|
base_version = nil
|
||||||
else
|
base_snapshot = nil
|
||||||
render json: post.errors, status: :unprocessable_entity
|
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)
|
||||||
|
current_snapshot = post_snapshot_from_record(post)
|
||||||
|
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
|
end
|
||||||
|
|
||||||
|
return render json: conflict_json, status: :conflict if conflict_json
|
||||||
|
|
||||||
|
post.reload
|
||||||
|
json = PostRepr.base(post, current_user)
|
||||||
|
json['tags'] = build_tag_tree_for(post.tags)
|
||||||
|
render json:, status: :ok
|
||||||
rescue Tag::NicoTagNormalisationError
|
rescue Tag::NicoTagNormalisationError
|
||||||
head :bad_request
|
head :bad_request
|
||||||
|
rescue ArgumentError => e
|
||||||
|
render json: { errors: [e.message] }, status: :unprocessable_entity
|
||||||
|
rescue ActiveRecord::RecordInvalid => e
|
||||||
|
render json: { errors: e.record.errors.full_messages }, status: :unprocessable_entity
|
||||||
end
|
end
|
||||||
|
|
||||||
def changes
|
def changes
|
||||||
@@ -198,7 +260,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: { tag_name: :wiki_page })
|
tag: [:deerjikists, :materials, { tag_name: :wiki_page }])
|
||||||
|
|
||||||
events = []
|
events = []
|
||||||
pts.each do |pt|
|
pts.each do |pt|
|
||||||
@@ -340,4 +402,242 @@ class PostsController < ApplicationController
|
|||||||
|
|
||||||
root_ids.filter_map { |id| build_node.call(id, []) }
|
root_ids.filter_map { |id| build_node.call(id, []) }
|
||||||
end
|
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(:base, '自分自身を親投稿にはできません.')
|
||||||
|
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(:base, "存在しない親投稿 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
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -7,7 +7,16 @@ class TagChildrenController < ApplicationController
|
|||||||
child_id = params[:child_id]
|
child_id = params[:child_id]
|
||||||
return head :bad_request if parent_id.blank? || child_id.blank?
|
return head :bad_request if parent_id.blank? || child_id.blank?
|
||||||
|
|
||||||
Tag.find(parent_id).children << Tag.find(child_id) rescue nil
|
parent = Tag.find(parent_id)
|
||||||
|
child = Tag.find(child_id)
|
||||||
|
return head :bad_request if parent.nico? || child.nico?
|
||||||
|
|
||||||
|
ApplicationRecord.transaction do
|
||||||
|
TagVersioning.ensure_snapshot!(child, created_by_user: current_user)
|
||||||
|
|
||||||
|
TagImplication.find_or_create_by!(parent_tag: parent, tag: child)
|
||||||
|
TagVersionRecorder.record!(tag: child, event_type: :update, created_by_user: current_user)
|
||||||
|
end
|
||||||
|
|
||||||
head :no_content
|
head :no_content
|
||||||
end
|
end
|
||||||
@@ -20,7 +29,16 @@ class TagChildrenController < ApplicationController
|
|||||||
child_id = params[:child_id]
|
child_id = params[:child_id]
|
||||||
return head :bad_request if parent_id.blank? || child_id.blank?
|
return head :bad_request if parent_id.blank? || child_id.blank?
|
||||||
|
|
||||||
Tag.find(parent_id).children.delete(Tag.find(child_id)) rescue nil
|
parent = Tag.find(parent_id)
|
||||||
|
child = Tag.find(child_id)
|
||||||
|
return head :bad_request if parent.nico? || child.nico?
|
||||||
|
|
||||||
|
ApplicationRecord.transaction do
|
||||||
|
TagVersioning.ensure_snapshot!(child, created_by_user: current_user)
|
||||||
|
|
||||||
|
TagImplication.find_by(parent_tag: parent, tag: child)&.destroy!
|
||||||
|
TagVersionRecorder.record!(tag: child, event_type: :update, created_by_user: current_user)
|
||||||
|
end
|
||||||
|
|
||||||
head :no_content
|
head :no_content
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
class TagVersionsController < ApplicationController
|
||||||
|
def index
|
||||||
|
tag_id = params[:id].presence
|
||||||
|
page = (params[:page].presence || 1).to_i
|
||||||
|
limit = (params[:limit].presence || 20).to_i
|
||||||
|
|
||||||
|
page = 1 if page < 1
|
||||||
|
limit = 1 if limit < 1
|
||||||
|
|
||||||
|
offset = (page - 1) * limit
|
||||||
|
|
||||||
|
q = TagVersion.joins(<<~SQL.squish)
|
||||||
|
LEFT JOIN
|
||||||
|
tag_versions prev
|
||||||
|
ON
|
||||||
|
prev.tag_id = tag_versions.tag_id
|
||||||
|
AND prev.version_no = tag_versions.version_no - 1
|
||||||
|
SQL
|
||||||
|
.select('tag_versions.*', 'prev.name AS prev_name', 'prev.category AS prev_category',
|
||||||
|
'prev.aliases AS prev_aliases', 'prev.parent_tag_ids AS prev_parent_tag_ids')
|
||||||
|
q = q.where('tag_versions.tag_id = ?', tag_id) if tag_id
|
||||||
|
|
||||||
|
count = q.except(:select, :order, :limit, :offset).count
|
||||||
|
|
||||||
|
versions = q.order(Arel.sql('tag_versions.created_at DESC, tag_versions.id DESC'))
|
||||||
|
.limit(limit)
|
||||||
|
.offset(offset)
|
||||||
|
|
||||||
|
render json: { versions: serialise_versions(versions), count: }
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def serialise_versions rows
|
||||||
|
user_ids = rows.map(&:created_by_user_id).compact.uniq
|
||||||
|
users_by_id = User.where(id: user_ids).pluck(:id, :name).to_h
|
||||||
|
|
||||||
|
rows.map do |row|
|
||||||
|
cur_aliases = split_values(row.aliases)
|
||||||
|
prev_aliases = split_values(row.attributes['prev_aliases'])
|
||||||
|
|
||||||
|
cur_parent_tag_ids = split_parent_tag_ids(row.parent_tag_ids)
|
||||||
|
prev_parent_tag_ids = split_parent_tag_ids(row.attributes['prev_parent_tag_ids'])
|
||||||
|
|
||||||
|
all_parent_tag_ids = (cur_parent_tag_ids | prev_parent_tag_ids)
|
||||||
|
|
||||||
|
tags_by_id =
|
||||||
|
Tag
|
||||||
|
.includes(:tag_name, :materials, { tag_name: :wiki_page })
|
||||||
|
.where(id: all_parent_tag_ids)
|
||||||
|
.index_by(&:id)
|
||||||
|
|
||||||
|
parent_tags =
|
||||||
|
build_version_values(cur_parent_tag_ids, prev_parent_tag_ids, key: :tag_id)
|
||||||
|
.map do |h|
|
||||||
|
{ tag: TagRepr.base(tags_by_id[h[:tag_id]]),
|
||||||
|
type: h[:type] }
|
||||||
|
end
|
||||||
|
|
||||||
|
{ tag_id: row.tag_id,
|
||||||
|
version_no: row.version_no,
|
||||||
|
event_type: row.event_type,
|
||||||
|
name: { current: row.name, prev: row.attributes['prev_name'] },
|
||||||
|
category: { current: row.category, prev: row.attributes['prev_category'] },
|
||||||
|
aliases: build_version_values(cur_aliases, prev_aliases, key: :name),
|
||||||
|
parent_tags:,
|
||||||
|
created_at: row.created_at.iso8601,
|
||||||
|
created_by_user: row.created_by_user_id &&
|
||||||
|
{ id: row.created_by_user_id,
|
||||||
|
name: users_by_id[row.created_by_user_id] } }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_version_values cur_values, prev_values, key:
|
||||||
|
(cur_values | prev_values).map do |value|
|
||||||
|
type =
|
||||||
|
if cur_values.include?(value) && prev_values.include?(value)
|
||||||
|
'context'
|
||||||
|
elsif cur_values.include?(value)
|
||||||
|
'added'
|
||||||
|
else
|
||||||
|
'removed'
|
||||||
|
end
|
||||||
|
|
||||||
|
{ key => value, type: }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def split_values(values) = values.to_s.split(/\s+/).reject(&:blank?)
|
||||||
|
|
||||||
|
def split_parent_tag_ids(values) = split_values(values).map(&:to_i)
|
||||||
|
end
|
||||||
@@ -1,3 +1,7 @@
|
|||||||
|
require 'net/http'
|
||||||
|
require 'uri'
|
||||||
|
|
||||||
|
|
||||||
class TagsController < ApplicationController
|
class TagsController < ApplicationController
|
||||||
def index
|
def index
|
||||||
post_id = params[:post]
|
post_id = params[:post]
|
||||||
@@ -33,11 +37,11 @@ class TagsController < ApplicationController
|
|||||||
else
|
else
|
||||||
Tag.joins(:tag_name)
|
Tag.joins(:tag_name)
|
||||||
end
|
end
|
||||||
.includes(:tag_name, tag_name: :wiki_page)
|
.includes(:tag_name, :materials, tag_name: :wiki_page)
|
||||||
q = q.where(posts: { id: post_id }) if post_id.present?
|
q = q.where(posts: { id: post_id }) if post_id.present?
|
||||||
|
|
||||||
q = q.where('tag_names.name LIKE ?', "%#{ name }%") if name
|
q = q.where('tag_names.name LIKE ?', "%#{ name }%") if name
|
||||||
q = q.where(category: category) if category
|
q = q.where(category:) if category
|
||||||
q = q.where('tags.post_count >= ?', post_count_between[0]) if post_count_between[0]
|
q = q.where('tags.post_count >= ?', post_count_between[0]) if post_count_between[0]
|
||||||
q = q.where('tags.post_count <= ?', post_count_between[1]) if post_count_between[1]
|
q = q.where('tags.post_count <= ?', post_count_between[1]) if post_count_between[1]
|
||||||
q = q.where('tags.created_at >= ?', created_between[0]) if created_between[0]
|
q = q.where('tags.created_at >= ?', created_between[0]) if created_between[0]
|
||||||
@@ -66,7 +70,45 @@ class TagsController < ApplicationController
|
|||||||
.offset(offset)
|
.offset(offset)
|
||||||
.to_a
|
.to_a
|
||||||
|
|
||||||
render json: { tags: TagRepr.base(tags), count: q.size }
|
render json: { tags: TagRepr.many(tags), count: q.size }
|
||||||
|
end
|
||||||
|
|
||||||
|
def with_depth
|
||||||
|
parent_tag_id = params[:parent].to_i
|
||||||
|
parent_tag_id = nil if parent_tag_id <= 0
|
||||||
|
|
||||||
|
tag_ids =
|
||||||
|
if parent_tag_id
|
||||||
|
TagImplication.where(parent_tag_id:).select(:tag_id)
|
||||||
|
else
|
||||||
|
Tag.where.not(id: TagImplication.select(:tag_id)).select(:id)
|
||||||
|
end
|
||||||
|
|
||||||
|
tags =
|
||||||
|
Tag
|
||||||
|
.joins(:tag_name)
|
||||||
|
.includes(:tag_name, :materials, tag_name: :wiki_page)
|
||||||
|
.where(category: [:meme, :character, :material])
|
||||||
|
.where(id: tag_ids)
|
||||||
|
.order('tag_names.name')
|
||||||
|
.distinct
|
||||||
|
.to_a
|
||||||
|
|
||||||
|
has_children_tag_ids =
|
||||||
|
if tags.empty?
|
||||||
|
[]
|
||||||
|
else
|
||||||
|
TagImplication
|
||||||
|
.joins(:tag)
|
||||||
|
.where(parent_tag_id: tags.map(&:id),
|
||||||
|
tags: { category: [:meme, :character, :material] })
|
||||||
|
.distinct
|
||||||
|
.pluck(:parent_tag_id)
|
||||||
|
end
|
||||||
|
|
||||||
|
render json: tags.map { |tag|
|
||||||
|
TagRepr.base(tag).merge(has_children: has_children_tag_ids.include?(tag.id), children: [])
|
||||||
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
def autocomplete
|
def autocomplete
|
||||||
@@ -90,7 +132,7 @@ class TagsController < ApplicationController
|
|||||||
end
|
end
|
||||||
|
|
||||||
base = Tag.joins(:tag_name)
|
base = Tag.joins(:tag_name)
|
||||||
.includes(:tag_name, tag_name: :wiki_page)
|
.includes(:tag_name, :materials, tag_name: :wiki_page)
|
||||||
base = base.where('tags.post_count > 0') if present_only
|
base = base.where('tags.post_count > 0') if present_only
|
||||||
|
|
||||||
canonical_hit =
|
canonical_hit =
|
||||||
@@ -115,7 +157,7 @@ class TagsController < ApplicationController
|
|||||||
|
|
||||||
def show
|
def show
|
||||||
tag = Tag.joins(:tag_name)
|
tag = Tag.joins(:tag_name)
|
||||||
.includes(:tag_name, tag_name: :wiki_page)
|
.includes(:tag_name, :materials, tag_name: :wiki_page)
|
||||||
.find_by(id: params[:id])
|
.find_by(id: params[:id])
|
||||||
if tag
|
if tag
|
||||||
render json: TagRepr.base(tag)
|
render json: TagRepr.base(tag)
|
||||||
@@ -129,7 +171,7 @@ class TagsController < ApplicationController
|
|||||||
return head :bad_request 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, :materials, tag_name: :wiki_page)
|
||||||
.find_by(tag_names: { name: })
|
.find_by(tag_names: { name: })
|
||||||
if tag
|
if tag
|
||||||
render json: TagRepr.base(tag)
|
render json: TagRepr.base(tag)
|
||||||
@@ -144,7 +186,8 @@ 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: DeerjikistRepr.many(tag.deerjikists)
|
render json: { tag: TagRepr.base(tag),
|
||||||
|
deerjikists: DeerjikistRepr.many(tag.deerjikists) }
|
||||||
end
|
end
|
||||||
|
|
||||||
def deerjikists_by_name
|
def deerjikists_by_name
|
||||||
@@ -156,7 +199,97 @@ class TagsController < ApplicationController
|
|||||||
.find_by(tag_names: { name: })
|
.find_by(tag_names: { name: })
|
||||||
return head :not_found unless tag
|
return head :not_found unless tag
|
||||||
|
|
||||||
render json: DeerjikistRepr.many(tag.deerjikists)
|
render json: { tag: TagRepr.base(tag),
|
||||||
|
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 do
|
||||||
|
platform = _1[:platform]
|
||||||
|
code = normalise_deerjikist_code(platform, _1[:code])
|
||||||
|
deerjikist = Deerjikist.find_or_initialize_by(platform:, code:)
|
||||||
|
deerjikist.tag = tag
|
||||||
|
deerjikist.save!
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
render json: DeerjikistRepr.many(tag.reload.deerjikists)
|
||||||
|
end
|
||||||
|
|
||||||
|
def materials_by_name
|
||||||
|
name = params[:name].to_s.strip
|
||||||
|
return head :bad_request if name.blank?
|
||||||
|
|
||||||
|
tag = Tag.joins(:tag_name)
|
||||||
|
.includes(:tag_name, :materials, tag_name: :wiki_page)
|
||||||
|
.find_by(tag_names: { name: })
|
||||||
|
return head :not_found unless tag
|
||||||
|
|
||||||
|
render json: build_tag_children(tag)
|
||||||
|
end
|
||||||
|
|
||||||
|
def update_all
|
||||||
|
return head :unauthorized unless current_user
|
||||||
|
return head :forbidden unless current_user.gte_member?
|
||||||
|
|
||||||
|
tag = Tag.find_by(id: params[:id])
|
||||||
|
return head :not_found unless tag
|
||||||
|
|
||||||
|
name = params[:name].to_s.strip
|
||||||
|
category = params[:category].to_s.strip
|
||||||
|
return head :unprocessable_entity if name.blank? || category.blank?
|
||||||
|
|
||||||
|
if name != tag.name &&
|
||||||
|
tag.in?([Tag.tagme, Tag.bot, Tag.no_deerjikist, Tag.video, Tag.niconico])
|
||||||
|
return render json: { error: 'システム・タグの名称は変更できません.' },
|
||||||
|
status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
|
||||||
|
if tag.nico? || category == 'nico'
|
||||||
|
return render json: { error: 'ニコタグは変更できません.' },
|
||||||
|
status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
|
||||||
|
alias_names = params[:aliases].to_s.split.uniq
|
||||||
|
parent_names = params[:parent_tags].to_s.split.uniq
|
||||||
|
|
||||||
|
ApplicationRecord.transaction do
|
||||||
|
TagVersioning.ensure_snapshot!(tag, created_by_user: current_user)
|
||||||
|
|
||||||
|
old_name = tag.name
|
||||||
|
name_changed = name != old_name
|
||||||
|
wiki_page = tag.tag_name.wiki_page if name_changed
|
||||||
|
|
||||||
|
tag.update!(category:)
|
||||||
|
tag.tag_name.update!(name:)
|
||||||
|
|
||||||
|
alias_names << old_name if name_changed
|
||||||
|
alias_names.delete(name)
|
||||||
|
|
||||||
|
update_aliases!(tag, alias_names)
|
||||||
|
update_parent_tags!(tag, parent_names)
|
||||||
|
|
||||||
|
tag.reload
|
||||||
|
|
||||||
|
record_tag_version!(
|
||||||
|
tag,
|
||||||
|
event_type: :update,
|
||||||
|
created_by_user: current_user,
|
||||||
|
name_changed:,
|
||||||
|
wiki_page:)
|
||||||
|
end
|
||||||
|
|
||||||
|
render json: TagRepr.base(tag.reload)
|
||||||
end
|
end
|
||||||
|
|
||||||
def update
|
def update
|
||||||
@@ -168,14 +301,140 @@ class TagsController < ApplicationController
|
|||||||
|
|
||||||
tag = Tag.find(params[:id])
|
tag = Tag.find(params[:id])
|
||||||
|
|
||||||
if name.present?
|
if tag.nico? || (category.present? && category == 'nico')
|
||||||
tag.tag_name.update!(name:)
|
return render json: { error: 'ニコタグは変更できません.' },
|
||||||
|
status: :unprocessable_entity
|
||||||
end
|
end
|
||||||
|
|
||||||
if category.present?
|
ApplicationRecord.transaction do
|
||||||
tag.update!(category:)
|
TagVersioning.ensure_snapshot!(tag, created_by_user: current_user)
|
||||||
|
|
||||||
|
old_name = tag.name
|
||||||
|
name_changed = name.present? && name != old_name
|
||||||
|
wiki_page = tag.tag_name.wiki_page if name_changed
|
||||||
|
|
||||||
|
tag.tag_name.update!(name:) if name.present?
|
||||||
|
tag.update!(category:) if category.present?
|
||||||
|
|
||||||
|
tag.reload
|
||||||
|
|
||||||
|
record_tag_version!(
|
||||||
|
tag,
|
||||||
|
event_type: :update,
|
||||||
|
created_by_user: current_user,
|
||||||
|
name_changed:,
|
||||||
|
wiki_page:)
|
||||||
end
|
end
|
||||||
|
|
||||||
render json: TagRepr.base(tag)
|
render json: TagRepr.base(tag.reload)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def build_tag_children tag
|
||||||
|
material = tag.materials.first
|
||||||
|
file = nil
|
||||||
|
content_type = nil
|
||||||
|
if material&.file&.attached?
|
||||||
|
file = rails_storage_proxy_url(material.file, only_path: false)
|
||||||
|
content_type = material.file.blob.content_type
|
||||||
|
end
|
||||||
|
|
||||||
|
TagRepr.base(tag).merge(
|
||||||
|
children: tag.children.sort_by { _1.name }.map { build_tag_children(_1) },
|
||||||
|
material: material.as_json&.merge(file:, content_type:))
|
||||||
|
end
|
||||||
|
|
||||||
|
def record_tag_version! tag, event_type:, created_by_user:, name_changed: false, wiki_page: nil
|
||||||
|
if tag.nico?
|
||||||
|
NicoTagVersionRecorder.record!(tag:, event_type:, created_by_user:)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
TagVersionRecorder.record!(tag:, event_type:, created_by_user:)
|
||||||
|
|
||||||
|
return unless name_changed
|
||||||
|
|
||||||
|
wiki_page ||= tag.tag_name.wiki_page
|
||||||
|
return unless wiki_page&.wiki_versions&.exists?
|
||||||
|
|
||||||
|
WikiVersionRecorder.record!(
|
||||||
|
page: wiki_page,
|
||||||
|
event_type: :update,
|
||||||
|
created_by_user:)
|
||||||
|
end
|
||||||
|
|
||||||
|
def update_aliases! tag, alias_names
|
||||||
|
alias_names = alias_names.uniq
|
||||||
|
|
||||||
|
affected_tags = [tag]
|
||||||
|
|
||||||
|
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|
|
||||||
|
next if alias_names.include?(alias_tag_name.name)
|
||||||
|
|
||||||
|
alias_tag_name.update!(canonical: nil)
|
||||||
|
end
|
||||||
|
|
||||||
|
alias_names.each do |alias_name|
|
||||||
|
alias_tag_name = TagName.find_undiscard_or_create_by!(name: alias_name)
|
||||||
|
alias_tag_name.update!(canonical: tag.tag_name)
|
||||||
|
end
|
||||||
|
|
||||||
|
affected_tags.compact.uniq.each do |affected_tag|
|
||||||
|
record_tag_version!(affected_tag, event_type: :update, created_by_user: current_user)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def update_parent_tags! tag, parent_names
|
||||||
|
parent_tags = Tag.normalise_tags!(parent_names, with_tagme: false,
|
||||||
|
with_no_deerjikist: false,
|
||||||
|
deny_nico: true)
|
||||||
|
|
||||||
|
old_parent_tags = tag.parents.to_a
|
||||||
|
|
||||||
|
TagVersioning.record_tag_snapshots!((old_parent_tags + parent_tags).uniq,
|
||||||
|
created_by_user: current_user)
|
||||||
|
|
||||||
|
tag.reversed_tag_implications.destroy_all
|
||||||
|
|
||||||
|
parent_tags.each do |parent_tag|
|
||||||
|
next if parent_tag == tag
|
||||||
|
|
||||||
|
TagImplication.create!(tag:, parent_tag:)
|
||||||
|
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
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,18 +1,22 @@
|
|||||||
class UsersController < ApplicationController
|
class UsersController < ApplicationController
|
||||||
def create
|
def create
|
||||||
user = User.create!(inheritance_code: SecureRandom.uuid, role: 'guest')
|
user = nil
|
||||||
|
User.transaction do
|
||||||
|
user = User.create!(inheritance_code: SecureRandom.uuid, role: :guest)
|
||||||
|
attach_ip_address!(user)
|
||||||
|
end
|
||||||
|
|
||||||
render json: { code: user.inheritance_code,
|
render json: { code: user.inheritance_code,
|
||||||
user: user.slice(:id, :name, :inheritance_code, :role) }
|
user: user.slice(:id, :name, :inheritance_code, :role) },
|
||||||
|
status: :created
|
||||||
end
|
end
|
||||||
|
|
||||||
def verify
|
def verify
|
||||||
ip_bin = IPAddr.new(request.remote_ip).hton
|
|
||||||
ip_address = IpAddress.find_or_create_by!(ip_address: ip_bin)
|
|
||||||
|
|
||||||
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?
|
||||||
|
|
||||||
UserIp.find_or_create_by!(user:, ip_address:)
|
attach_ip_address!(user)
|
||||||
|
|
||||||
render json: { valid: true, user: user.slice(:id, :name, :inheritance_code, :role) }
|
render json: { valid: true, user: user.slice(:id, :name, :inheritance_code, :role) }
|
||||||
end
|
end
|
||||||
@@ -41,9 +45,18 @@ class UsersController < ApplicationController
|
|||||||
return head :bad_request 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: :created
|
render json: user.slice(:id, :name, :inheritance_code, :role), status: :ok
|
||||||
else
|
else
|
||||||
render json: user.errors, status: :unprocessable_entity
|
render json: user.errors, status: :unprocessable_entity
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def attach_ip_address! user
|
||||||
|
ip_bin = IPAddr.new(request.remote_ip).hton
|
||||||
|
ip_address = IpAddress.create_or_find_by!(ip_address: ip_bin)
|
||||||
|
|
||||||
|
UserIp.create_or_find_by!(user:, ip_address:)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -85,22 +85,24 @@ class WikiPagesController < 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?
|
||||||
|
|
||||||
name = params[:title]&.strip
|
title = params[:title].to_s.strip
|
||||||
body = params[:body].to_s
|
body = params[:body].to_s
|
||||||
|
message = params[:message].presence
|
||||||
|
|
||||||
return head :unprocessable_entity if name.blank? || body.blank?
|
return head :unprocessable_entity if title.blank? || body.blank?
|
||||||
|
|
||||||
tag_name = TagName.find_undiscard_or_create_by!(name:)
|
tag_name = TagName.find_undiscard_or_create_by!(name: title)
|
||||||
page = WikiPage.new(tag_name:, created_user: current_user, updated_user: current_user)
|
|
||||||
if page.save
|
|
||||||
message = params[:message].presence
|
|
||||||
Wiki::Commit.content!(page:, body:, created_user: current_user, message:)
|
|
||||||
|
|
||||||
render json: WikiPageRepr.base(page), status: :created
|
page =
|
||||||
else
|
Wiki::Commit.create_content!(
|
||||||
render json: { errors: page.errors.full_messages },
|
tag_name:,
|
||||||
status: :unprocessable_entity
|
body:,
|
||||||
end
|
created_by_user: current_user,
|
||||||
|
message:)
|
||||||
|
|
||||||
|
render json: WikiPageRepr.base(page), status: :created
|
||||||
|
rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotUnique
|
||||||
|
head :unprocessable_entity
|
||||||
end
|
end
|
||||||
|
|
||||||
def update
|
def update
|
||||||
@@ -113,19 +115,34 @@ class WikiPagesController < ApplicationController
|
|||||||
return head :unprocessable_entity if title.blank? || body.blank?
|
return head :unprocessable_entity if title.blank? || body.blank?
|
||||||
|
|
||||||
page = WikiPage.find(params[:id])
|
page = WikiPage.find(params[:id])
|
||||||
base_revision_id = page.current_revision.id
|
base_revision_id = params[:base_revision_id].presence
|
||||||
|
|
||||||
if params[:title].present? && params[:title].strip != page.title
|
ApplicationRecord.transaction do
|
||||||
return head :unprocessable_entity
|
page.lock!
|
||||||
|
|
||||||
|
old_title = page.title
|
||||||
|
|
||||||
|
tag = Tag.find_by(tag_name_id: page.tag_name_id)
|
||||||
|
|
||||||
|
if tag && title != old_title
|
||||||
|
TagVersioning.ensure_snapshot!(tag, created_by_user: current_user)
|
||||||
|
end
|
||||||
|
|
||||||
|
page.tag_name.update!(name: title) if title != old_title
|
||||||
|
|
||||||
|
message = params[:message].presence
|
||||||
|
Wiki::Commit.content!(page:,
|
||||||
|
body:,
|
||||||
|
created_user: current_user,
|
||||||
|
message:,
|
||||||
|
base_revision_id:)
|
||||||
|
|
||||||
|
if tag && title != old_title
|
||||||
|
tag.reload
|
||||||
|
TagVersionRecorder.record!(tag:, event_type: :update, created_by_user: current_user)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
message = params[:message].presence
|
|
||||||
Wiki::Commit.content!(page:,
|
|
||||||
body:,
|
|
||||||
created_user: current_user,
|
|
||||||
message:,
|
|
||||||
base_revision_id:)
|
|
||||||
|
|
||||||
head :ok
|
head :ok
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
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 :users
|
has_many :user_ips, dependent: :destroy
|
||||||
|
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
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
class Material < ApplicationRecord
|
||||||
|
include MyDiscard
|
||||||
|
|
||||||
|
default_scope -> { kept }
|
||||||
|
|
||||||
|
belongs_to :parent, class_name: 'Material', optional: true
|
||||||
|
has_many :children, class_name: 'Material', foreign_key: :parent_id, dependent: :nullify
|
||||||
|
|
||||||
|
belongs_to :tag, optional: true
|
||||||
|
belongs_to :created_by_user, class_name: 'User', optional: true
|
||||||
|
belongs_to :updated_by_user, class_name: 'User', optional: true
|
||||||
|
|
||||||
|
has_one_attached :file, dependent: :purge
|
||||||
|
|
||||||
|
validates :tag_id, presence: true, uniqueness: true
|
||||||
|
|
||||||
|
validate :file_must_be_attached
|
||||||
|
validate :tag_must_be_material_category
|
||||||
|
|
||||||
|
def content_type
|
||||||
|
return nil unless file&.attached?
|
||||||
|
|
||||||
|
file.blob.content_type
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def file_must_be_attached
|
||||||
|
return if url.present? || file.attached?
|
||||||
|
|
||||||
|
errors.add(:url, 'URL かファイルのどちらかは必須です.')
|
||||||
|
end
|
||||||
|
|
||||||
|
def tag_must_be_material_category
|
||||||
|
return if tag.blank? || tag.character? || tag.material?
|
||||||
|
|
||||||
|
errors.add(:tag, '素材カテゴリのタグを指定してください.')
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,7 +1,11 @@
|
|||||||
module MyDiscard
|
module MyDiscard
|
||||||
extend ActiveSupport::Concern
|
extend ActiveSupport::Concern
|
||||||
|
|
||||||
included { include Discard::Model }
|
included do
|
||||||
|
include Discard::Model
|
||||||
|
|
||||||
|
default_scope -> { kept }
|
||||||
|
end
|
||||||
|
|
||||||
class_methods do
|
class_methods do
|
||||||
def find_undiscard_or_create_by! attrs, &block
|
def find_undiscard_or_create_by! attrs, &block
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
class NicoTagVersion < ApplicationRecord
|
||||||
|
include VersionRecord
|
||||||
|
|
||||||
|
belongs_to :tag
|
||||||
|
|
||||||
|
validates :name, presence: true
|
||||||
|
end
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
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
|
||||||
@@ -11,9 +10,26 @@ class Post < ApplicationRecord
|
|||||||
|
|
||||||
has_many :user_post_views, dependent: :delete_all
|
has_many :user_post_views, dependent: :delete_all
|
||||||
has_many :post_similarities, dependent: :delete_all
|
has_many :post_similarities, dependent: :delete_all
|
||||||
|
has_many :post_versions
|
||||||
|
|
||||||
|
has_many :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
|
||||||
@@ -21,15 +37,29 @@ 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_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
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
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
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
class PostVersion < ApplicationRecord
|
||||||
|
include VersionRecord
|
||||||
|
|
||||||
|
belongs_to :post
|
||||||
|
belongs_to :parent, class_name: 'Post', optional: true
|
||||||
|
|
||||||
|
validates :url, presence: true
|
||||||
|
|
||||||
|
validate :validate_original_created_range
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def validate_original_created_range
|
||||||
|
f = original_created_from
|
||||||
|
b = original_created_before
|
||||||
|
return if f.blank? || b.blank?
|
||||||
|
|
||||||
|
if f >= b
|
||||||
|
errors.add :original_created_before, 'オリジナルの作成日時の順番がをかしぃです.'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
+46
-23
@@ -1,3 +1,6 @@
|
|||||||
|
require 'set'
|
||||||
|
|
||||||
|
|
||||||
class Tag < ApplicationRecord
|
class Tag < ApplicationRecord
|
||||||
include MyDiscard
|
include MyDiscard
|
||||||
|
|
||||||
@@ -5,8 +8,6 @@ class Tag < ApplicationRecord
|
|||||||
;
|
;
|
||||||
end
|
end
|
||||||
|
|
||||||
default_scope -> { kept }
|
|
||||||
|
|
||||||
has_many :post_tags, inverse_of: :tag
|
has_many :post_tags, inverse_of: :tag
|
||||||
has_many :active_post_tags, -> { kept }, class_name: 'PostTag', inverse_of: :tag
|
has_many :active_post_tags, -> { kept }, class_name: 'PostTag', inverse_of: :tag
|
||||||
has_many :post_tags_with_discarded, -> { with_discarded }, class_name: 'PostTag'
|
has_many :post_tags_with_discarded, -> { with_discarded }, class_name: 'PostTag'
|
||||||
@@ -31,10 +32,16 @@ class Tag < ApplicationRecord
|
|||||||
class_name: 'TagSimilarity', foreign_key: :target_tag_id, dependent: :delete_all
|
class_name: 'TagSimilarity', foreign_key: :target_tag_id, dependent: :delete_all
|
||||||
|
|
||||||
has_many :deerjikists, dependent: :delete_all
|
has_many :deerjikists, dependent: :delete_all
|
||||||
|
has_many :materials
|
||||||
|
|
||||||
|
has_many :tag_versions
|
||||||
|
has_many :nico_tag_versions
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
@@ -72,27 +79,20 @@ class Tag < ApplicationRecord
|
|||||||
|
|
||||||
def has_wiki = wiki_page.present?
|
def has_wiki = wiki_page.present?
|
||||||
|
|
||||||
def self.tagme
|
def material_id = materials.first&.id
|
||||||
@tagme ||= find_or_create_by_tag_name!('タグ希望', category: :meta)
|
|
||||||
end
|
|
||||||
|
|
||||||
def self.bot
|
def has_deerjikists = deerjikists.present?
|
||||||
@bot ||= find_or_create_by_tag_name!('bot操作', category: :meta)
|
|
||||||
end
|
|
||||||
|
|
||||||
def self.no_deerjikist
|
def self.tagme = find_or_create_by_tag_name!('タグ希望', category: :meta)
|
||||||
@no_deerjikist ||= find_or_create_by_tag_name!('ニジラー情報不詳', category: :meta)
|
def self.bot = find_or_create_by_tag_name!('bot操作', category: :meta)
|
||||||
end
|
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.niconico = find_or_create_by_tag_name!('ニコニコ', category: :meta)
|
||||||
|
def self.youtube = find_or_create_by_tag_name!('YouTube', category: :meta)
|
||||||
|
|
||||||
def self.video
|
def self.normalise_tags! tag_names, with_tagme: true,
|
||||||
@video ||= find_or_create_by_tag_name!('動画', category: :meta)
|
with_no_deerjikist: true,
|
||||||
end
|
deny_nico: true
|
||||||
|
|
||||||
def self.niconico
|
|
||||||
@niconico ||= find_or_create_by_tag_name!('ニコニコ', category: :meta)
|
|
||||||
end
|
|
||||||
|
|
||||||
def self.normalise_tags tag_names, with_tagme: 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:') }
|
||||||
raise NicoTagNormalisationError
|
raise NicoTagNormalisationError
|
||||||
end
|
end
|
||||||
@@ -106,7 +106,7 @@ class Tag < ApplicationRecord
|
|||||||
end
|
end
|
||||||
|
|
||||||
tags << Tag.tagme if with_tagme && tags.size < 10 && tags.none?(Tag.tagme)
|
tags << Tag.tagme if with_tagme && tags.size < 10 && tags.none?(Tag.tagme)
|
||||||
tags << Tag.no_deerjikist if tags.all? { |t| !(t.deerjikist?) }
|
tags << Tag.no_deerjikist if with_no_deerjikist && tags.all? { |t| !(t.deerjikist?) }
|
||||||
tags.uniq(&:id)
|
tags.uniq(&:id)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -144,18 +144,25 @@ class Tag < ApplicationRecord
|
|||||||
retry
|
retry
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.merge_tags! target_tag, source_tags
|
def self.merge_tags! target_tag, source_tags, created_by_user: nil
|
||||||
target_tag => Tag
|
target_tag => Tag
|
||||||
|
|
||||||
|
affected_post_ids = Set.new
|
||||||
|
|
||||||
Tag.transaction do
|
Tag.transaction do
|
||||||
|
TagVersioning.ensure_snapshot!(target_tag, created_by_user:)
|
||||||
|
|
||||||
Array(source_tags).compact.uniq.each do |source_tag|
|
Array(source_tags).compact.uniq.each do |source_tag|
|
||||||
source_tag => Tag
|
source_tag => Tag
|
||||||
|
|
||||||
next if source_tag == target_tag
|
next if source_tag == target_tag
|
||||||
|
|
||||||
|
TagVersioning.ensure_snapshot!(source_tag, created_by_user:)
|
||||||
|
|
||||||
source_tag.post_tags.kept.find_each do |source_pt|
|
source_tag.post_tags.kept.find_each do |source_pt|
|
||||||
post_id = source_pt.post_id
|
post_id = source_pt.post_id
|
||||||
source_pt.discard_by!(nil)
|
affected_post_ids << post_id
|
||||||
|
source_pt.discard_by!(created_by_user)
|
||||||
unless PostTag.kept.exists?(post_id:, tag: target_tag)
|
unless PostTag.kept.exists?(post_id:, tag: target_tag)
|
||||||
PostTag.create!(post_id:, tag: target_tag)
|
PostTag.create!(post_id:, tag: target_tag)
|
||||||
end
|
end
|
||||||
@@ -167,6 +174,7 @@ class Tag < ApplicationRecord
|
|||||||
raise ActiveRecord::RecordInvalid.new(source_tag_name)
|
raise ActiveRecord::RecordInvalid.new(source_tag_name)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
TagVersioning.record!(source_tag, event_type: :discard, created_by_user:)
|
||||||
source_tag.discard!
|
source_tag.discard!
|
||||||
|
|
||||||
if source_tag.nico?
|
if source_tag.nico?
|
||||||
@@ -175,6 +183,13 @@ class Tag < ApplicationRecord
|
|||||||
source_tag_name.update_columns(canonical_id: target_tag.tag_name_id,
|
source_tag_name.update_columns(canonical_id: target_tag.tag_name_id,
|
||||||
updated_at: Time.current)
|
updated_at: Time.current)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
TagVersioning.record!(target_tag, event_type: :update, created_by_user:)
|
||||||
|
end
|
||||||
|
|
||||||
|
Post.where(id: affected_post_ids.to_a).find_each do |post|
|
||||||
|
PostVersionRecorder.ensure_snapshot!(post, created_by_user:)
|
||||||
|
PostVersionRecorder.record!(post:, event_type: :update, created_by_user:)
|
||||||
end
|
end
|
||||||
|
|
||||||
# 投稿件数を再集計
|
# 投稿件数を再集計
|
||||||
@@ -184,6 +199,14 @@ class Tag < ApplicationRecord
|
|||||||
target_tag.reload
|
target_tag.reload
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def snapshot_aliases = tag_name.aliases.kept.order(:name).pluck(:name)
|
||||||
|
|
||||||
|
def snapshot_parent_tag_ids = parents.order(:id).pluck(:id)
|
||||||
|
|
||||||
|
def snapshot_linked_tag_names
|
||||||
|
linked_tags.joins(:tag_name).order('tag_names.name').pluck('tag_names.name')
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def nico_tag_name_must_start_with_nico
|
def nico_tag_name_must_start_with_nico
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
class TagName < ApplicationRecord
|
class TagName < ApplicationRecord
|
||||||
include MyDiscard
|
include MyDiscard
|
||||||
|
|
||||||
default_scope -> { kept }
|
|
||||||
|
|
||||||
has_one :tag
|
has_one :tag
|
||||||
has_one :wiki_page
|
has_one :wiki_page
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
class TagVersion < ApplicationRecord
|
||||||
|
include VersionRecord
|
||||||
|
|
||||||
|
belongs_to :tag
|
||||||
|
|
||||||
|
enum :category, { deerjikist: 'deerjikist',
|
||||||
|
meme: 'meme',
|
||||||
|
character: 'character',
|
||||||
|
general: 'general',
|
||||||
|
material: 'material',
|
||||||
|
meta: 'meta' }, validate: true
|
||||||
|
|
||||||
|
validates :name, presence: true
|
||||||
|
validates :category, presence: true
|
||||||
|
end
|
||||||
@@ -4,7 +4,6 @@ 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
|
||||||
@@ -19,5 +18,10 @@ 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
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
module VersionRecord
|
||||||
|
extend ActiveSupport::Concern
|
||||||
|
|
||||||
|
def readonly? = persisted?
|
||||||
|
|
||||||
|
included do
|
||||||
|
belongs_to :created_by_user, class_name: 'User', optional: true
|
||||||
|
|
||||||
|
enum :event_type, { create: 'create',
|
||||||
|
update: 'update',
|
||||||
|
discard: 'discard',
|
||||||
|
restore: 'restore' }, prefix: true, validate: true
|
||||||
|
|
||||||
|
validates :version_no, presence: true, numericality: { only_integer: true, greater_than: 0 }
|
||||||
|
validates :event_type, presence: true
|
||||||
|
|
||||||
|
scope :chronological, -> { order(:version_no, :id) }
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -4,8 +4,6 @@ require 'set'
|
|||||||
class WikiPage < ApplicationRecord
|
class WikiPage < ApplicationRecord
|
||||||
include MyDiscard
|
include MyDiscard
|
||||||
|
|
||||||
default_scope -> { kept }
|
|
||||||
|
|
||||||
has_many :wiki_revisions, dependent: :destroy
|
has_many :wiki_revisions, dependent: :destroy
|
||||||
belongs_to :created_user, class_name: 'User'
|
belongs_to :created_user, class_name: 'User'
|
||||||
belongs_to :updated_user, class_name: 'User'
|
belongs_to :updated_user, class_name: 'User'
|
||||||
@@ -15,8 +13,13 @@ class WikiPage < ApplicationRecord
|
|||||||
foreign_key: :redirect_page_id,
|
foreign_key: :redirect_page_id,
|
||||||
dependent: :nullify
|
dependent: :nullify
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
def title = tag_name.name
|
def title = tag_name.name
|
||||||
|
|
||||||
@@ -26,11 +29,6 @@ class WikiPage < ApplicationRecord
|
|||||||
|
|
||||||
def current_revision = wiki_revisions.order(id: :desc).first
|
def current_revision = wiki_revisions.order(id: :desc).first
|
||||||
|
|
||||||
def body
|
|
||||||
rev = current_revision
|
|
||||||
rev.body if rev&.content?
|
|
||||||
end
|
|
||||||
|
|
||||||
def resolve_redirect limit: 10
|
def resolve_redirect limit: 10
|
||||||
page = self
|
page = self
|
||||||
visited = Set.new
|
visited = Set.new
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
class WikiVersion < ApplicationRecord
|
||||||
|
include VersionRecord
|
||||||
|
|
||||||
|
belongs_to :wiki_page
|
||||||
|
|
||||||
|
validates :title, presence: true
|
||||||
|
validates :body, presence: true
|
||||||
|
end
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
|
||||||
|
module MaterialRepr
|
||||||
|
BASE = { only: [:id, :url, :created_at, :updated_at],
|
||||||
|
methods: [:content_type],
|
||||||
|
include: { tag: TagRepr::BASE,
|
||||||
|
created_by_user: UserRepr::BASE,
|
||||||
|
updated_by_user: UserRepr::BASE } }.freeze
|
||||||
|
|
||||||
|
module_function
|
||||||
|
|
||||||
|
def base material, host:
|
||||||
|
material.as_json(BASE).merge(
|
||||||
|
file: if material.file.attached?
|
||||||
|
Rails.application.routes.url_helpers.rails_storage_proxy_url(
|
||||||
|
material.file, host:)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
def many materials, host:
|
||||||
|
materials.map { |m| base(m, host:) }
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
|
|
||||||
module PostRepr
|
module PostRepr
|
||||||
BASE = { include: { tags: TagRepr::BASE, uploaded_user: UserRepr::BASE } }.freeze
|
BASE = { include: { tags: TagRepr::BASE, uploaded_user: UserRepr::BASE },
|
||||||
|
methods: [:parent_posts, :child_posts, :sibling_posts] }.freeze
|
||||||
|
|
||||||
module_function
|
module_function
|
||||||
|
|
||||||
|
|||||||
@@ -3,15 +3,14 @@
|
|||||||
|
|
||||||
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] }.freeze
|
methods: [:name, :has_wiki, :material_id, :has_deerjikists] }.freeze
|
||||||
|
|
||||||
module_function
|
module_function
|
||||||
|
|
||||||
def base tag
|
def base tag
|
||||||
tag.as_json(BASE)
|
tag.as_json(BASE).merge(aliases: tag.snapshot_aliases,
|
||||||
|
parents: tag.parents.map { _1.as_json(BASE) })
|
||||||
end
|
end
|
||||||
|
|
||||||
def many tags
|
def many(tags) = tags.map { |t| base(t) }
|
||||||
tags.map { |t| base(t) }
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
class NicoTagVersionRecorder < VersionRecorder
|
||||||
|
def self.record! tag:, event_type:, created_by_user:
|
||||||
|
new(tag:, event_type:, created_by_user:).record!
|
||||||
|
end
|
||||||
|
|
||||||
|
def initialize tag:, event_type:, created_by_user:
|
||||||
|
super(record: tag, event_type:, created_by_user:)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def version_class = NicoTagVersion
|
||||||
|
def version_association = :nico_tag_versions
|
||||||
|
def record_key = :tag
|
||||||
|
|
||||||
|
def snapshot_attributes
|
||||||
|
{ name: @record.name, linked_tags: @record.snapshot_linked_tag_names.join(' ') }
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
class PostVersionRecorder < VersionRecorder
|
||||||
|
def self.record! post:, event_type:, created_by_user:
|
||||||
|
new(post:, event_type:, created_by_user:).record!
|
||||||
|
end
|
||||||
|
|
||||||
|
def initialize post:, event_type:, created_by_user:
|
||||||
|
super(record: post, event_type:, created_by_user:)
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.ensure_snapshot! post, created_by_user:
|
||||||
|
return if post.post_versions.exists?
|
||||||
|
|
||||||
|
record!(post:, event_type: :create, created_by_user:)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def version_class = PostVersion
|
||||||
|
def version_association = :post_versions
|
||||||
|
def record_key = :post
|
||||||
|
|
||||||
|
def snapshot_attributes
|
||||||
|
{ title: @record.title,
|
||||||
|
url: @record.url,
|
||||||
|
thumbnail_base: @record.thumbnail_base,
|
||||||
|
tags: @record.snapshot_tag_names.join(' '),
|
||||||
|
parent_post_ids: @record.snapshot_parent_post_ids.join(' '),
|
||||||
|
original_created_from: @record.original_created_from,
|
||||||
|
original_created_before: @record.original_created_before }
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
class TagVersionRecorder < VersionRecorder
|
||||||
|
def self.record! tag:, event_type:, created_by_user:
|
||||||
|
new(tag:, event_type:, created_by_user:).record!
|
||||||
|
end
|
||||||
|
|
||||||
|
def initialize tag:, event_type:, created_by_user:
|
||||||
|
super(record: tag, event_type:, created_by_user:)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def version_class = TagVersion
|
||||||
|
def version_association = :tag_versions
|
||||||
|
def record_key = :tag
|
||||||
|
|
||||||
|
def snapshot_attributes
|
||||||
|
{ name: @record.name,
|
||||||
|
category: @record.category,
|
||||||
|
aliases: @record.snapshot_aliases.join(' '),
|
||||||
|
parent_tag_ids: @record.snapshot_parent_tag_ids.join(' ') }
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
class TagVersioning
|
||||||
|
def self.record! tag, event_type:, created_by_user:
|
||||||
|
if tag.nico?
|
||||||
|
NicoTagVersionRecorder.record!(tag:, event_type:, created_by_user:)
|
||||||
|
else
|
||||||
|
TagVersionRecorder.record!(tag:, event_type:, created_by_user:)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.ensure_snapshot! tag, created_by_user:
|
||||||
|
if tag.nico?
|
||||||
|
return if tag.nico_tag_versions.exists?
|
||||||
|
|
||||||
|
NicoTagVersionRecorder.record!(tag:, event_type: :create, created_by_user:)
|
||||||
|
else
|
||||||
|
return if tag.tag_versions.exists?
|
||||||
|
|
||||||
|
TagVersionRecorder.record!(tag:, event_type: :create, created_by_user:)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.record_tag_snapshot! tag, created_by_user:
|
||||||
|
event_type =
|
||||||
|
if tag.nico?
|
||||||
|
tag.nico_tag_versions.exists? ? :update : :create
|
||||||
|
else
|
||||||
|
tag.tag_versions.exists? ? :update : :create
|
||||||
|
end
|
||||||
|
|
||||||
|
record!(tag, event_type:, created_by_user:)
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.record_tag_snapshots! tags, created_by_user:
|
||||||
|
tags.each do |tag|
|
||||||
|
record_tag_snapshot!(tag, created_by_user:)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
class VersionRecorder
|
||||||
|
EVENT_TYPES = ['create', 'update', 'discard', 'restore'].freeze
|
||||||
|
|
||||||
|
def initialize record:, event_type:, created_by_user:
|
||||||
|
@record = record
|
||||||
|
@event_type = event_type.to_s
|
||||||
|
@created_by_user = created_by_user
|
||||||
|
|
||||||
|
validate_event_type!
|
||||||
|
end
|
||||||
|
|
||||||
|
def record!
|
||||||
|
raise "#{ record_class.name } must be persisted" unless @record.persisted?
|
||||||
|
|
||||||
|
ApplicationRecord.transaction do
|
||||||
|
@record = record_class.unscoped.lock.find(@record.id)
|
||||||
|
latest = latest_version
|
||||||
|
|
||||||
|
validate_version_sequence!(latest)
|
||||||
|
|
||||||
|
attrs = snapshot_attributes
|
||||||
|
|
||||||
|
if @event_type == 'update' && latest && same_snapshot?(latest, attrs)
|
||||||
|
return latest
|
||||||
|
end
|
||||||
|
|
||||||
|
version = version_class.create!(
|
||||||
|
base_attributes(latest).merge(record_key => @record).merge(attrs))
|
||||||
|
|
||||||
|
update_record_version_no!(version.version_no)
|
||||||
|
|
||||||
|
version
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def latest_version = versions.order(version_no: :desc).first
|
||||||
|
|
||||||
|
def versions = @record.public_send(version_association)
|
||||||
|
|
||||||
|
def base_attributes latest
|
||||||
|
{ version_no: (latest&.version_no || 0) + 1,
|
||||||
|
event_type: @event_type,
|
||||||
|
created_at: Time.current,
|
||||||
|
created_by_user: @created_by_user }
|
||||||
|
end
|
||||||
|
|
||||||
|
def update_record_version_no! version_no
|
||||||
|
@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!
|
||||||
|
return if EVENT_TYPES.include?(@event_type)
|
||||||
|
|
||||||
|
raise ArgumentError, "Invalid event_type: #{ @event_type }"
|
||||||
|
end
|
||||||
|
|
||||||
|
def version_class = raise NotImplementedError
|
||||||
|
def version_association = raise NotImplementedError
|
||||||
|
def record_key = raise NotImplementedError
|
||||||
|
def snapshot_attributes = raise NotImplementedError
|
||||||
|
|
||||||
|
def record_class = @record.class
|
||||||
|
end
|
||||||
@@ -7,6 +7,31 @@ module Wiki
|
|||||||
;
|
;
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def self.create_content! tag_name:, body:, created_by_user:, message: nil
|
||||||
|
normalised = normalise_body(body)
|
||||||
|
|
||||||
|
page = WikiPage.new(tag_name:,
|
||||||
|
body: normalised,
|
||||||
|
created_user: created_by_user,
|
||||||
|
updated_user: created_by_user)
|
||||||
|
|
||||||
|
if normalised.blank?
|
||||||
|
page.errors.add(:body, :blank)
|
||||||
|
raise ActiveRecord::RecordInvalid, page
|
||||||
|
end
|
||||||
|
|
||||||
|
ActiveRecord::Base.transaction do
|
||||||
|
page.save!
|
||||||
|
|
||||||
|
new(page:, created_user: created_by_user).content!(
|
||||||
|
body: normalised,
|
||||||
|
message:,
|
||||||
|
base_revision_id: nil)
|
||||||
|
|
||||||
|
page
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def self.content! page:, body:, created_user:, message: nil, base_revision_id: nil
|
def self.content! page:, body:, created_user:, message: nil, base_revision_id: nil
|
||||||
new(page:, created_user:).content!(body:, message:, base_revision_id:)
|
new(page:, created_user:).content!(body:, message:, base_revision_id:)
|
||||||
end
|
end
|
||||||
@@ -21,7 +46,12 @@ module Wiki
|
|||||||
end
|
end
|
||||||
|
|
||||||
def content! body:, message:, base_revision_id:
|
def content! body:, message:, base_revision_id:
|
||||||
normalised = normalise_body(body)
|
normalised = self.class.normalise_body(body)
|
||||||
|
if normalised.blank?
|
||||||
|
@page.errors.add(:body, :blank)
|
||||||
|
raise ActiveRecord::RecordInvalid, @page
|
||||||
|
end
|
||||||
|
|
||||||
lines = split_lines(normalised)
|
lines = split_lines(normalised)
|
||||||
|
|
||||||
line_shas = lines.map { |line| Digest::SHA256.hexdigest(line) }
|
line_shas = lines.map { |line| Digest::SHA256.hexdigest(line) }
|
||||||
@@ -37,10 +67,19 @@ module Wiki
|
|||||||
current_id = @page.wiki_revisions.maximum(:id)
|
current_id = @page.wiki_revisions.maximum(:id)
|
||||||
if current_id && current_id != base_revision_id.to_i
|
if current_id && current_id != base_revision_id.to_i
|
||||||
raise Conflict,
|
raise Conflict,
|
||||||
"競合が発生してゐます(現在の Id.:#{ current_id },ベース Id.:#{ base_revision_id })."
|
"競合が発生してゐます" +
|
||||||
|
"(現在の Id.:#{ current_id },ベース Id.:#{ base_revision_id })."
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@page.update!(body: normalised)
|
||||||
|
|
||||||
|
WikiVersionRecorder.record!(
|
||||||
|
page: @page,
|
||||||
|
event_type: @page.wiki_versions.exists? ? :update : :create,
|
||||||
|
reason: message,
|
||||||
|
created_by_user: @created_user)
|
||||||
|
|
||||||
rev = WikiRevision.create!(
|
rev = WikiRevision.create!(
|
||||||
wiki_page: @page,
|
wiki_page: @page,
|
||||||
base_revision_id:,
|
base_revision_id:,
|
||||||
@@ -54,65 +93,45 @@ module Wiki
|
|||||||
rows = line_ids.each_with_index.map do |line_id, pos|
|
rows = line_ids.each_with_index.map do |line_id, pos|
|
||||||
{ wiki_revision_id: rev.id, wiki_line_id: line_id, position: pos }
|
{ wiki_revision_id: rev.id, wiki_line_id: line_id, position: pos }
|
||||||
end
|
end
|
||||||
WikiRevisionLine.insert_all!(rows)
|
WikiRevisionLine.insert_all!(rows) if rows.any?
|
||||||
|
|
||||||
rev
|
rev
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def redirect! redirect_page:, message:, base_revision_id:
|
def redirect!(redirect_page:, message:, base_revision_id:) = raise '廃止しました.'
|
||||||
ActiveRecord::Base.transaction do
|
|
||||||
@page.lock!
|
|
||||||
|
|
||||||
if base_revision_id.present?
|
def self.normalise_body body
|
||||||
current_id = @page.wiki_revisions.maximum(:id)
|
s = body.to_s
|
||||||
if current_id && current_id != base_revision_id.to_i
|
s.gsub!(/\r\n?/, "\n")
|
||||||
raise Conflict,
|
s.encode('UTF-8', invalid: :replace, undef: :replace, replace: '🖕')
|
||||||
"競合が発生してゐます(現在の Id.:#{ current_id },ベース Id.:#{ base_revision_id })."
|
s.gsub(/\n+$/, '')
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
WikiRevision.create!(
|
|
||||||
wiki_page: @page,
|
|
||||||
base_revision_id:,
|
|
||||||
created_user: @created_user,
|
|
||||||
kind: :redirect,
|
|
||||||
redirect_page:,
|
|
||||||
message:,
|
|
||||||
lines_count: 0,
|
|
||||||
tree_sha256: nil)
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def normalise_body body
|
def split_lines(body) = body.split("\n")
|
||||||
s = body.to_s
|
|
||||||
s.gsub!("\r\n", "\n")
|
|
||||||
s.encode('UTF-8', invalid: :replace, undef: :replace, replace: '🖕')
|
|
||||||
end
|
|
||||||
|
|
||||||
def split_lines body
|
|
||||||
body.split("\n")
|
|
||||||
end
|
|
||||||
|
|
||||||
def upsert_lines! lines, line_shas
|
def upsert_lines! lines, line_shas
|
||||||
now = Time.current
|
now = Time.current
|
||||||
|
|
||||||
id_by_sha = WikiLine.where(sha256: line_shas).pluck(:sha256, :id).to_h
|
id_by_sha = WikiLine.where(sha256: line_shas).pluck(:sha256, :id).to_h
|
||||||
|
|
||||||
missing_rows = []
|
missing_by_sha = { }
|
||||||
|
|
||||||
line_shas.each_with_index do |sha, i|
|
line_shas.each_with_index do |sha, i|
|
||||||
next if id_by_sha.key?(sha)
|
next if id_by_sha.key?(sha)
|
||||||
|
next if missing_by_sha.key?(sha)
|
||||||
|
|
||||||
missing_rows << { sha256: sha,
|
missing_by_sha[sha] = {
|
||||||
body: lines[i],
|
sha256: sha,
|
||||||
created_at: now,
|
body: lines[i],
|
||||||
updated_at: now }
|
created_at: now,
|
||||||
|
updated_at: now }
|
||||||
end
|
end
|
||||||
|
|
||||||
if missing_rows.any?
|
if missing_by_sha.any?
|
||||||
WikiLine.upsert_all(missing_rows)
|
WikiLine.upsert_all(missing_by_sha.values)
|
||||||
id_by_sha = WikiLine.where(sha256: line_shas).pluck(:sha256, :id).to_h
|
id_by_sha = WikiLine.where(sha256: line_shas).pluck(:sha256, :id).to_h
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
class WikiVersionRecorder < VersionRecorder
|
||||||
|
def self.record! page:, event_type:, reason: nil, created_by_user:
|
||||||
|
new(page:, event_type:, reason:, created_by_user:).record!
|
||||||
|
end
|
||||||
|
|
||||||
|
def initialize page:, event_type:, reason: nil, created_by_user:
|
||||||
|
@reason = reason
|
||||||
|
super(record: page, event_type:, created_by_user:)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def version_class = WikiVersion
|
||||||
|
def version_association = :wiki_versions
|
||||||
|
def record_key = :wiki_page
|
||||||
|
|
||||||
|
def snapshot_attributes = {
|
||||||
|
title: @record.title,
|
||||||
|
body: @record.body,
|
||||||
|
reason: @reason }
|
||||||
|
end
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
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
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
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
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
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
|
||||||
@@ -18,8 +18,7 @@ Rails.application.configure do
|
|||||||
# Enable serving of images, stylesheets, and JavaScripts from an asset server.
|
# Enable serving of images, stylesheets, and JavaScripts from an asset server.
|
||||||
# config.asset_host = "http://assets.example.com"
|
# config.asset_host = "http://assets.example.com"
|
||||||
|
|
||||||
# Store uploaded files on the local file system (see config/storage.yml for options).
|
config.active_storage.service = :r2
|
||||||
config.active_storage.service = :local
|
|
||||||
|
|
||||||
# Assume all access to the app is happening through a SSL-terminating reverse proxy.
|
# Assume all access to the app is happening through a SSL-terminating reverse proxy.
|
||||||
config.assume_ssl = true
|
config.assume_ssl = true
|
||||||
|
|||||||
@@ -50,4 +50,6 @@ Rails.application.configure do
|
|||||||
|
|
||||||
# Raise error when a before_action's only/except options reference missing actions.
|
# Raise error when a before_action's only/except options reference missing actions.
|
||||||
config.action_controller.raise_on_missing_callback_actions = true
|
config.action_controller.raise_on_missing_callback_actions = true
|
||||||
|
|
||||||
|
Rails.application.routes.default_url_options[:host] = 'www.example.com'
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -6,18 +6,25 @@ Rails.application.routes.draw do
|
|||||||
delete ':child_id', action: :destroy
|
delete ':child_id', action: :destroy
|
||||||
end
|
end
|
||||||
|
|
||||||
resources :tags, only: [:index, :show, :update] do
|
resources :tags, only: [:index, :show] do
|
||||||
collection do
|
collection do
|
||||||
get :autocomplete
|
get :autocomplete
|
||||||
|
get :'with-depth', action: :with_depth
|
||||||
|
get :versions, to: 'tag_versions#index'
|
||||||
|
|
||||||
scope :name do
|
scope :name do
|
||||||
get ':name/deerjikists', action: :deerjikists_by_name
|
get ':name/deerjikists', action: :deerjikists_by_name
|
||||||
|
get ':name/materials', action: :materials_by_name
|
||||||
get ':name', action: :show_by_name
|
get ':name', action: :show_by_name
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
member do
|
member do
|
||||||
|
put '', action: :update_all
|
||||||
|
patch '', action: :update
|
||||||
|
|
||||||
get :deerjikists
|
get :deerjikists
|
||||||
|
put :deerjikists, action: :update_deerjikists
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -47,6 +54,7 @@ Rails.application.routes.draw do
|
|||||||
collection do
|
collection do
|
||||||
get :random
|
get :random
|
||||||
get :changes
|
get :changes
|
||||||
|
get :versions, to: 'post_versions#index'
|
||||||
end
|
end
|
||||||
|
|
||||||
member do
|
member do
|
||||||
@@ -81,4 +89,6 @@ Rails.application.routes.draw do
|
|||||||
|
|
||||||
resources :comments, controller: :theatre_comments, only: [:index, :create]
|
resources :comments, controller: :theatre_comments, only: [:index, :create]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
resources :materials, only: [:index, :show, :create, :update, :destroy]
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,12 +1,27 @@
|
|||||||
env :PATH, '/root/.rbenv/shims:/root/.rbenv/bin:/usr/local/bin:/usr/bin:/bin'
|
env :PATH, '/root/.rbenv/shims:/root/.rbenv/bin:/usr/local/bin:/usr/bin:/bin'
|
||||||
|
|
||||||
|
set :path, '/var/www/btrc-hub/backend'
|
||||||
|
set :environment, 'production'
|
||||||
set :output, standard: '/var/log/btrc_hub_nico_sync.log',
|
set :output, standard: '/var/log/btrc_hub_nico_sync.log',
|
||||||
error: '/var/log/btrc_hub_nico_sync_err.log'
|
error: '/var/log/btrc_hub_nico_sync_err.log'
|
||||||
|
|
||||||
every 1.day, at: '3:00 pm' do
|
job_type :rake,
|
||||||
|
'cd :path && set -a && . /etc/btrc-hub/backend.env && set +a && ' \
|
||||||
|
':environment_variable=:environment bundle exec rake :task --silent :output'
|
||||||
|
|
||||||
|
every 1.day, at: '11:00 am' do
|
||||||
rake 'nico:sync', environment: 'production'
|
rake 'nico:sync', environment: 'production'
|
||||||
end
|
end
|
||||||
|
|
||||||
every 1.day, at: '0:00 am' do
|
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'
|
||||||
|
end
|
||||||
|
|
||||||
|
every 1.day, at: '7:50 am' do
|
||||||
|
rake 'nico:export', environment: 'production'
|
||||||
|
end
|
||||||
|
|
||||||
|
every :hour do
|
||||||
|
rake 'post:sync', environment: 'production'
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -6,29 +6,11 @@ local:
|
|||||||
service: Disk
|
service: Disk
|
||||||
root: <%= Rails.root.join("storage") %>
|
root: <%= Rails.root.join("storage") %>
|
||||||
|
|
||||||
# Use bin/rails credentials:edit to set the AWS secrets (as aws:access_key_id|secret_access_key)
|
r2:
|
||||||
# amazon:
|
service: S3
|
||||||
# service: S3
|
endpoint: <%= ENV['R2_ENDPOINT'] %>
|
||||||
# access_key_id: <%= Rails.application.credentials.dig(:aws, :access_key_id) %>
|
access_key_id: <%= ENV['R2_ACCESS_KEY_ID'] %>
|
||||||
# secret_access_key: <%= Rails.application.credentials.dig(:aws, :secret_access_key) %>
|
secret_access_key: <%= ENV['R2_SECRET_ACCESS_KEY'] %>
|
||||||
# region: us-east-1
|
bucket: <%= ENV['R2_BUCKET'] %>
|
||||||
# bucket: your_own_bucket-<%= Rails.env %>
|
region: auto
|
||||||
|
request_checksum_calculation: when_required
|
||||||
# Remember not to checkin your GCS keyfile to a repository
|
|
||||||
# google:
|
|
||||||
# service: GCS
|
|
||||||
# project: your_project
|
|
||||||
# credentials: <%= Rails.root.join("path/to/gcs.keyfile") %>
|
|
||||||
# bucket: your_own_bucket-<%= Rails.env %>
|
|
||||||
|
|
||||||
# Use bin/rails credentials:edit to set the Azure Storage secret (as azure_storage:storage_access_key)
|
|
||||||
# microsoft:
|
|
||||||
# service: AzureStorage
|
|
||||||
# storage_account_name: your_account_name
|
|
||||||
# storage_access_key: <%= Rails.application.credentials.dig(:azure_storage, :storage_access_key) %>
|
|
||||||
# container: your_container_name-<%= Rails.env %>
|
|
||||||
|
|
||||||
# mirror:
|
|
||||||
# service: Mirror
|
|
||||||
# primary: local
|
|
||||||
# mirrors: [ amazon, google, microsoft ]
|
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
class CreateMaterials < ActiveRecord::Migration[8.0]
|
||||||
|
def change
|
||||||
|
create_table :materials do |t|
|
||||||
|
t.string :url
|
||||||
|
t.references :parent, index: true, foreign_key: { to_table: :materials }
|
||||||
|
t.references :tag, index: true, foreign_key: true
|
||||||
|
t.references :created_by_user, foreign_key: { to_table: :users }
|
||||||
|
t.references :updated_by_user, foreign_key: { to_table: :users }
|
||||||
|
t.timestamps
|
||||||
|
t.datetime :discarded_at, index: true
|
||||||
|
t.virtual :active_url, type: :string,
|
||||||
|
as: 'IF(discarded_at IS NULL, url, NULL)',
|
||||||
|
stored: false
|
||||||
|
|
||||||
|
t.index :active_url, unique: true
|
||||||
|
end
|
||||||
|
|
||||||
|
create_table :material_versions do |t|
|
||||||
|
t.references :material, null: false, foreign_key: true
|
||||||
|
t.integer :version_no, null: false
|
||||||
|
t.string :url, index: true
|
||||||
|
t.references :parent, index: true, foreign_key: { to_table: :materials }
|
||||||
|
t.references :tag, index: true, foreign_key: true
|
||||||
|
t.references :created_by_user, foreign_key: { to_table: :users }
|
||||||
|
t.references :updated_by_user, foreign_key: { to_table: :users }
|
||||||
|
t.timestamps
|
||||||
|
t.datetime :discarded_at, index: true
|
||||||
|
|
||||||
|
t.index [:material_id, :version_no],
|
||||||
|
unique: true,
|
||||||
|
name: 'index_material_versions_on_material_id_and_version_no'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
require 'set'
|
||||||
|
|
||||||
|
|
||||||
|
class CreatePostVersions < ActiveRecord::Migration[8.0]
|
||||||
|
class Post < ActiveRecord::Base
|
||||||
|
self.table_name = 'posts'
|
||||||
|
end
|
||||||
|
|
||||||
|
class PostTag < ActiveRecord::Base
|
||||||
|
self.table_name = 'post_tags'
|
||||||
|
end
|
||||||
|
|
||||||
|
class PostVersion < ActiveRecord::Base
|
||||||
|
self.table_name = 'post_versions'
|
||||||
|
end
|
||||||
|
|
||||||
|
def up
|
||||||
|
create_table :post_versions do |t|
|
||||||
|
t.references :post, null: false, foreign_key: true
|
||||||
|
t.integer :version_no, null: false
|
||||||
|
t.string :event_type, null: false
|
||||||
|
t.string :title
|
||||||
|
t.string :url, limit: 768, null: false
|
||||||
|
t.string :thumbnail_base, limit: 2000
|
||||||
|
t.text :tags, null: false
|
||||||
|
t.references :parent, foreign_key: { to_table: :posts }
|
||||||
|
t.datetime :original_created_from
|
||||||
|
t.datetime :original_created_before
|
||||||
|
t.datetime :created_at, null: false
|
||||||
|
t.references :created_by_user, foreign_key: { to_table: :users }
|
||||||
|
|
||||||
|
t.index [:post_id, :version_no], unique: true
|
||||||
|
t.check_constraint 'version_no > 0',
|
||||||
|
name: 'post_versions_version_no_positive'
|
||||||
|
t.check_constraint "event_type IN ('create', 'update', 'discard', 'restore')",
|
||||||
|
name: 'post_versions_event_type_valid'
|
||||||
|
end
|
||||||
|
|
||||||
|
PostVersion.reset_column_information
|
||||||
|
|
||||||
|
say_with_time 'Backfilling post_versions' do
|
||||||
|
Post.find_in_batches(batch_size: 500) do |posts|
|
||||||
|
post_ids = posts.map(&:id)
|
||||||
|
|
||||||
|
post_tag_rows_by_post_id =
|
||||||
|
PostTag
|
||||||
|
.joins('INNER JOIN tags ON tags.id = post_tags.tag_id')
|
||||||
|
.joins('INNER JOIN tag_names ON tag_names.id = tags.tag_name_id')
|
||||||
|
.where(post_id: post_ids)
|
||||||
|
.pluck('post_tags.post_id',
|
||||||
|
'post_tags.created_at',
|
||||||
|
'post_tags.discarded_at',
|
||||||
|
'post_tags.created_user_id',
|
||||||
|
'post_tags.deleted_user_id',
|
||||||
|
'tag_names.name')
|
||||||
|
.each_with_object(Hash.new { |h, k| h[k] = [] }) do |row, h|
|
||||||
|
post_id, created_at, discarded_at, created_user_id, deleted_user_id, tag_name = row
|
||||||
|
h[post_id] << { created_at:,
|
||||||
|
discarded_at:,
|
||||||
|
created_user_id:,
|
||||||
|
deleted_user_id:,
|
||||||
|
tag_name: }
|
||||||
|
end
|
||||||
|
|
||||||
|
rows = []
|
||||||
|
|
||||||
|
posts.each do |post|
|
||||||
|
post_tag_rows = post_tag_rows_by_post_id[post.id]
|
||||||
|
|
||||||
|
events = post_tag_rows.flat_map do |post_tag_row|
|
||||||
|
ary = [[post_tag_row[:created_at],
|
||||||
|
post_tag_row[:created_user_id],
|
||||||
|
:add,
|
||||||
|
post_tag_row[:tag_name]]]
|
||||||
|
|
||||||
|
if post_tag_row[:discarded_at]
|
||||||
|
ary << [post_tag_row[:discarded_at],
|
||||||
|
post_tag_row[:deleted_user_id],
|
||||||
|
:remove,
|
||||||
|
post_tag_row[:tag_name]]
|
||||||
|
end
|
||||||
|
|
||||||
|
ary
|
||||||
|
end
|
||||||
|
|
||||||
|
kind_order = { add: 0, remove: 1 }
|
||||||
|
|
||||||
|
events.sort_by! do |event_at, user_id, kind, tag_name|
|
||||||
|
[event_at, user_id || 0, kind_order.fetch(kind), tag_name]
|
||||||
|
end
|
||||||
|
|
||||||
|
event_buckets = bucket_events(events)
|
||||||
|
|
||||||
|
active_tags = Set.new
|
||||||
|
version_no = 0
|
||||||
|
|
||||||
|
if event_buckets.empty?
|
||||||
|
version_no += 1
|
||||||
|
rows << build_row(post:,
|
||||||
|
version_no:,
|
||||||
|
event_type: 'create',
|
||||||
|
created_at: post.created_at,
|
||||||
|
created_by_user_id: post.uploaded_user_id,
|
||||||
|
tags: [])
|
||||||
|
next
|
||||||
|
end
|
||||||
|
|
||||||
|
first_bucket = event_buckets.first
|
||||||
|
merge_first_bucket_into_create = first_bucket[:first_at] <= post.created_at + 1.second
|
||||||
|
|
||||||
|
if merge_first_bucket_into_create
|
||||||
|
event_buckets.shift
|
||||||
|
apply_bucket!(active_tags, first_bucket)
|
||||||
|
|
||||||
|
version_no += 1
|
||||||
|
rows << build_row(
|
||||||
|
post:,
|
||||||
|
version_no:,
|
||||||
|
event_type: 'create',
|
||||||
|
created_at: post.created_at,
|
||||||
|
created_by_user_id: post.uploaded_user_id || first_bucket[:user_ids].compact.first,
|
||||||
|
tags: active_tags.to_a.sort)
|
||||||
|
else
|
||||||
|
version_no += 1
|
||||||
|
rows << build_row(
|
||||||
|
post:,
|
||||||
|
version_no:,
|
||||||
|
event_type: 'create',
|
||||||
|
created_at: post.created_at,
|
||||||
|
created_by_user_id: post.uploaded_user_id,
|
||||||
|
tags: [])
|
||||||
|
end
|
||||||
|
|
||||||
|
event_buckets.each do |bucket|
|
||||||
|
apply_bucket!(active_tags, bucket)
|
||||||
|
|
||||||
|
version_no += 1
|
||||||
|
rows << build_row(
|
||||||
|
post:,
|
||||||
|
version_no:,
|
||||||
|
event_type: 'update',
|
||||||
|
created_at: bucket[:first_at],
|
||||||
|
created_by_user_id: bucket[:user_ids].compact.first,
|
||||||
|
tags: active_tags.to_a.sort)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
PostVersion.insert_all!(rows) if rows.any?
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def down
|
||||||
|
drop_table :post_versions
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def bucket_events events
|
||||||
|
buckets = []
|
||||||
|
|
||||||
|
events.each do |event_at, user_id, kind, tag_name|
|
||||||
|
if buckets.empty? || event_at - buckets.last[:last_at] > 1.second
|
||||||
|
buckets << { first_at: event_at,
|
||||||
|
last_at: event_at,
|
||||||
|
user_ids: [user_id],
|
||||||
|
events: [[kind, tag_name]] }
|
||||||
|
else
|
||||||
|
bucket = buckets.last
|
||||||
|
bucket[:last_at] = event_at
|
||||||
|
bucket[:user_ids] << user_id
|
||||||
|
bucket[:events] << [kind, tag_name]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
buckets
|
||||||
|
end
|
||||||
|
|
||||||
|
def apply_bucket! active_tags, bucket
|
||||||
|
bucket[:events].each do |kind, tag_name|
|
||||||
|
if kind == :add
|
||||||
|
active_tags.add(tag_name)
|
||||||
|
else
|
||||||
|
active_tags.delete(tag_name)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_row post:, version_no:, event_type:, created_at:, created_by_user_id:, tags:
|
||||||
|
{ post_id: post.id,
|
||||||
|
version_no:,
|
||||||
|
event_type:,
|
||||||
|
title: post.title,
|
||||||
|
url: post.url,
|
||||||
|
thumbnail_base: post.thumbnail_base,
|
||||||
|
tags: tags.join(' '),
|
||||||
|
parent_id: post.parent_id,
|
||||||
|
original_created_from: post.original_created_from,
|
||||||
|
original_created_before: post.original_created_before,
|
||||||
|
created_at:,
|
||||||
|
created_by_user_id: }
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
class CreateTagVersions < ActiveRecord::Migration[8.0]
|
||||||
|
class Tag < ActiveRecord::Base
|
||||||
|
self.table_name = 'tags'
|
||||||
|
end
|
||||||
|
|
||||||
|
class TagName < ActiveRecord::Base
|
||||||
|
self.table_name = 'tag_names'
|
||||||
|
end
|
||||||
|
|
||||||
|
class TagImplication < ActiveRecord::Base
|
||||||
|
self.table_name = 'tag_implications'
|
||||||
|
end
|
||||||
|
|
||||||
|
class TagVersion < ActiveRecord::Base
|
||||||
|
self.table_name = 'tag_versions'
|
||||||
|
end
|
||||||
|
|
||||||
|
class NicoTagVersion < ActiveRecord::Base
|
||||||
|
self.table_name = 'nico_tag_versions'
|
||||||
|
end
|
||||||
|
|
||||||
|
class NicoTagRelation < ActiveRecord::Base
|
||||||
|
self.table_name = 'nico_tag_relations'
|
||||||
|
end
|
||||||
|
|
||||||
|
def up
|
||||||
|
create_table :tag_versions do |t|
|
||||||
|
t.references :tag, null: false, foreign_key: true, index: false
|
||||||
|
t.integer :version_no, null: false
|
||||||
|
t.string :event_type, null: false
|
||||||
|
t.string :name, null: false
|
||||||
|
t.string :category, null: false
|
||||||
|
t.text :aliases, null: false
|
||||||
|
t.text :parent_tag_ids, null: false
|
||||||
|
t.datetime :created_at, null: false
|
||||||
|
t.references :created_by_user, foreign_key: { to_table: :users }, index: false
|
||||||
|
|
||||||
|
t.index [:tag_id, :version_no], unique: true
|
||||||
|
t.index :created_at
|
||||||
|
t.index [:tag_id, :created_at], order: { created_at: :desc }
|
||||||
|
t.index [:created_by_user_id, :created_at], order: { created_at: :desc }
|
||||||
|
t.check_constraint 'version_no > 0',
|
||||||
|
name: 'tag_versions_version_no_positive'
|
||||||
|
end
|
||||||
|
|
||||||
|
create_table :nico_tag_versions do |t|
|
||||||
|
t.references :tag, null: false, foreign_key: true, index: false
|
||||||
|
t.integer :version_no, null: false
|
||||||
|
t.string :event_type, null: false
|
||||||
|
t.string :name, null: false
|
||||||
|
t.text :linked_tags, null: false
|
||||||
|
t.datetime :created_at, null: false
|
||||||
|
t.references :created_by_user, foreign_key: { to_table: :users }, index: false
|
||||||
|
|
||||||
|
t.index [:tag_id, :version_no], unique: true
|
||||||
|
t.index :created_at
|
||||||
|
t.index [:tag_id, :created_at], order: { created_at: :desc }
|
||||||
|
t.index [:created_by_user_id, :created_at], order: { created_at: :desc }
|
||||||
|
t.check_constraint 'version_no > 0',
|
||||||
|
name: 'nico_tag_versions_version_no_positive'
|
||||||
|
end
|
||||||
|
|
||||||
|
TagVersion.reset_column_information
|
||||||
|
say_with_time 'Backfilling tag_versions' do
|
||||||
|
Tag.where(discarded_at: nil)
|
||||||
|
.where.not(category: 'nico')
|
||||||
|
.find_in_batches(batch_size: 500) do |tags|
|
||||||
|
tag_ids = tags.map(&:id)
|
||||||
|
|
||||||
|
tag_implication_rows_by_tag_id =
|
||||||
|
TagImplication
|
||||||
|
.where(tag_id: tag_ids)
|
||||||
|
.pluck(:tag_id, :parent_tag_id)
|
||||||
|
.each_with_object(Hash.new { |h, k| h[k] = [] }) do |row, h|
|
||||||
|
h[row[0]] << row[1]
|
||||||
|
end
|
||||||
|
|
||||||
|
tag_name_rows_by_tag_id =
|
||||||
|
TagName
|
||||||
|
.joins('INNER JOIN tags ON tags.tag_name_id = tag_names.id')
|
||||||
|
.where(tags: { id: tag_ids })
|
||||||
|
.pluck('tags.id', 'tag_names.name')
|
||||||
|
.each_with_object({ }) do |row, h|
|
||||||
|
h[row[0]] = row[1]
|
||||||
|
end
|
||||||
|
|
||||||
|
tag_alias_rows_by_tag_id =
|
||||||
|
TagName
|
||||||
|
.joins('INNER JOIN tags ON tags.tag_name_id = tag_names.canonical_id')
|
||||||
|
.where(tags: { id: tag_ids })
|
||||||
|
.where(tag_names: { discarded_at: nil })
|
||||||
|
.pluck('tags.id', 'tag_names.name')
|
||||||
|
.each_with_object(Hash.new { |h, k| h[k] = [] }) do |row, h|
|
||||||
|
h[row[0]] << row[1]
|
||||||
|
end
|
||||||
|
|
||||||
|
TagVersion.insert_all(tags.map { |tag|
|
||||||
|
{ tag_id: tag.id,
|
||||||
|
version_no: 1,
|
||||||
|
event_type: 'create',
|
||||||
|
name: tag_name_rows_by_tag_id[tag.id],
|
||||||
|
category: tag.category,
|
||||||
|
aliases: tag_alias_rows_by_tag_id[tag.id].sort.join(' '),
|
||||||
|
parent_tag_ids: tag_implication_rows_by_tag_id[tag.id].sort.join(' '),
|
||||||
|
created_at: tag.created_at,
|
||||||
|
created_by_user_id: nil }
|
||||||
|
})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
NicoTagVersion.reset_column_information
|
||||||
|
say_with_time 'Backfilling nico_tag_versions' do
|
||||||
|
Tag.where(discarded_at: nil, category: 'nico')
|
||||||
|
.find_in_batches(batch_size: 500) do |tags|
|
||||||
|
tag_ids = tags.map(&:id)
|
||||||
|
|
||||||
|
tag_name_rows_by_tag_id =
|
||||||
|
TagName
|
||||||
|
.joins('INNER JOIN tags ON tags.tag_name_id = tag_names.id')
|
||||||
|
.where(tags: { id: tag_ids })
|
||||||
|
.pluck('tags.id', 'tag_names.name')
|
||||||
|
.each_with_object({ }) do |row, h|
|
||||||
|
h[row[0]] = row[1]
|
||||||
|
end
|
||||||
|
|
||||||
|
nico_tag_relation_rows_by_tag_id =
|
||||||
|
NicoTagRelation
|
||||||
|
.joins('INNER JOIN tags nico_tags ON nico_tags.id = nico_tag_relations.nico_tag_id')
|
||||||
|
.joins('INNER JOIN tags linked_tags ON linked_tags.id = nico_tag_relations.tag_id')
|
||||||
|
.joins('INNER JOIN tag_names ON tag_names.id = linked_tags.tag_name_id')
|
||||||
|
.where(nico_tags: { id: tag_ids })
|
||||||
|
.where(linked_tags: { discarded_at: nil })
|
||||||
|
.where(tag_names: { discarded_at: nil })
|
||||||
|
.pluck('nico_tags.id', 'tag_names.name')
|
||||||
|
.each_with_object(Hash.new { |h, k| h[k] = [] }) do |row, h|
|
||||||
|
h[row[0]] << row[1]
|
||||||
|
end
|
||||||
|
|
||||||
|
NicoTagVersion.insert_all(tags.map { |tag|
|
||||||
|
{ tag_id: tag.id,
|
||||||
|
version_no: 1,
|
||||||
|
event_type: 'create',
|
||||||
|
name: tag_name_rows_by_tag_id[tag.id],
|
||||||
|
linked_tags: nico_tag_relation_rows_by_tag_id[tag.id].sort.join(' '),
|
||||||
|
created_at: tag.created_at,
|
||||||
|
created_by_user_id: nil }
|
||||||
|
})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def down
|
||||||
|
drop_table :nico_tag_versions
|
||||||
|
drop_table :tag_versions
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
class CreateWikiVersions < ActiveRecord::Migration[8.0]
|
||||||
|
class WikiPage < ActiveRecord::Base
|
||||||
|
self.table_name = 'wiki_pages'
|
||||||
|
end
|
||||||
|
|
||||||
|
class WikiRevision < ActiveRecord::Base
|
||||||
|
self.table_name = 'wiki_revisions'
|
||||||
|
end
|
||||||
|
|
||||||
|
class WikiRevisionLine < ActiveRecord::Base
|
||||||
|
self.table_name = 'wiki_revision_lines'
|
||||||
|
end
|
||||||
|
|
||||||
|
class WikiLine < ActiveRecord::Base
|
||||||
|
self.table_name = 'wiki_lines'
|
||||||
|
end
|
||||||
|
|
||||||
|
class WikiVersion < ActiveRecord::Base
|
||||||
|
self.table_name = 'wiki_versions'
|
||||||
|
end
|
||||||
|
|
||||||
|
class TagName < ActiveRecord::Base
|
||||||
|
self.table_name = 'tag_names'
|
||||||
|
end
|
||||||
|
|
||||||
|
def up
|
||||||
|
add_column :wiki_pages, :body, :text, after: :tag_name_id
|
||||||
|
|
||||||
|
create_table :wiki_versions do |t|
|
||||||
|
t.references :wiki_page, null: false, foreign_key: true
|
||||||
|
t.integer :version_no, null: false
|
||||||
|
t.string :event_type, null: false
|
||||||
|
t.string :title, null: false
|
||||||
|
t.text :body, null: false
|
||||||
|
t.text :reason
|
||||||
|
t.datetime :created_at, null: false
|
||||||
|
t.references :created_by_user, foreign_key: { to_table: :users }
|
||||||
|
|
||||||
|
t.index [:wiki_page_id, :version_no], unique: true
|
||||||
|
t.check_constraint 'version_no > 0',
|
||||||
|
name: 'wiki_versions_version_no_positive'
|
||||||
|
t.check_constraint "event_type IN ('create', 'update', 'discard', 'restore')",
|
||||||
|
name: 'wiki_versions_event_type_valid'
|
||||||
|
end
|
||||||
|
|
||||||
|
WikiPage.reset_column_information
|
||||||
|
WikiVersion.reset_column_information
|
||||||
|
|
||||||
|
say_with_time 'Backfilling wiki_versions' do
|
||||||
|
WikiPage.find_each do |page|
|
||||||
|
base_revision_id = nil
|
||||||
|
version_no = 1
|
||||||
|
title = TagName.find(page.tag_name_id).name
|
||||||
|
body = nil
|
||||||
|
loop do
|
||||||
|
rev = WikiRevision.where(wiki_page_id: page.id).find_by(base_revision_id:)
|
||||||
|
break unless rev
|
||||||
|
|
||||||
|
body = WikiRevisionLine.where(wiki_revision_id: rev.id).order(:position).map { |wrl|
|
||||||
|
WikiLine.find(wrl.wiki_line_id).body
|
||||||
|
}.join("\n")
|
||||||
|
|
||||||
|
WikiVersion.create!(
|
||||||
|
wiki_page_id: page.id,
|
||||||
|
version_no:,
|
||||||
|
event_type: version_no == 1 ? 'create' : 'update',
|
||||||
|
title:,
|
||||||
|
body:,
|
||||||
|
reason: rev.message,
|
||||||
|
created_at: rev.created_at,
|
||||||
|
created_by_user_id: rev.created_user_id)
|
||||||
|
|
||||||
|
version_no += 1
|
||||||
|
base_revision_id = rev.id
|
||||||
|
end
|
||||||
|
if body
|
||||||
|
page.update!(body:)
|
||||||
|
else
|
||||||
|
page.destroy!
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
change_column_null :wiki_pages, :body, false
|
||||||
|
end
|
||||||
|
|
||||||
|
def down
|
||||||
|
drop_table :wiki_versions
|
||||||
|
remove_column :wiki_pages, :body
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
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
|
||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
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
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
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
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
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
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
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
|
||||||
Generated
+163
-6
@@ -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_03_17_015000) do
|
ActiveRecord::Schema[8.0].define(version: 2026_05_07_213300) 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,12 +50,52 @@ ActiveRecord::Schema[8.0].define(version: 2026_03_17_015000) 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.boolean "banned", default: false, null: false
|
t.datetime "banned_at"
|
||||||
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
|
||||||
|
|
||||||
|
create_table "material_versions", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||||
|
t.bigint "material_id", null: false
|
||||||
|
t.integer "version_no", null: false
|
||||||
|
t.string "url"
|
||||||
|
t.bigint "parent_id"
|
||||||
|
t.bigint "tag_id"
|
||||||
|
t.bigint "created_by_user_id"
|
||||||
|
t.bigint "updated_by_user_id"
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.datetime "updated_at", null: false
|
||||||
|
t.datetime "discarded_at"
|
||||||
|
t.index ["created_by_user_id"], name: "index_material_versions_on_created_by_user_id"
|
||||||
|
t.index ["discarded_at"], name: "index_material_versions_on_discarded_at"
|
||||||
|
t.index ["material_id", "version_no"], name: "index_material_versions_on_material_id_and_version_no", unique: true
|
||||||
|
t.index ["material_id"], name: "index_material_versions_on_material_id"
|
||||||
|
t.index ["parent_id"], name: "index_material_versions_on_parent_id"
|
||||||
|
t.index ["tag_id"], name: "index_material_versions_on_tag_id"
|
||||||
|
t.index ["updated_by_user_id"], name: "index_material_versions_on_updated_by_user_id"
|
||||||
|
t.index ["url"], name: "index_material_versions_on_url"
|
||||||
|
end
|
||||||
|
|
||||||
|
create_table "materials", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||||
|
t.string "url"
|
||||||
|
t.bigint "parent_id"
|
||||||
|
t.bigint "tag_id"
|
||||||
|
t.bigint "created_by_user_id"
|
||||||
|
t.bigint "updated_by_user_id"
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.datetime "updated_at", null: false
|
||||||
|
t.datetime "discarded_at"
|
||||||
|
t.virtual "active_url", type: :string, as: "if((`discarded_at` is null),`url`,NULL)"
|
||||||
|
t.index ["active_url"], name: "index_materials_on_active_url", unique: true
|
||||||
|
t.index ["created_by_user_id"], name: "index_materials_on_created_by_user_id"
|
||||||
|
t.index ["discarded_at"], name: "index_materials_on_discarded_at"
|
||||||
|
t.index ["parent_id"], name: "index_materials_on_parent_id"
|
||||||
|
t.index ["tag_id"], name: "index_materials_on_tag_id"
|
||||||
|
t.index ["updated_by_user_id"], name: "index_materials_on_updated_by_user_id"
|
||||||
|
end
|
||||||
|
|
||||||
create_table "nico_tag_relations", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
create_table "nico_tag_relations", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||||
t.bigint "nico_tag_id", null: false
|
t.bigint "nico_tag_id", null: false
|
||||||
t.bigint "tag_id", null: false
|
t.bigint "tag_id", null: false
|
||||||
@@ -65,6 +105,30 @@ ActiveRecord::Schema[8.0].define(version: 2026_03_17_015000) do
|
|||||||
t.index ["tag_id"], name: "index_nico_tag_relations_on_tag_id"
|
t.index ["tag_id"], name: "index_nico_tag_relations_on_tag_id"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
create_table "nico_tag_versions", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||||
|
t.bigint "tag_id", null: false
|
||||||
|
t.integer "version_no", null: false
|
||||||
|
t.string "event_type", null: false
|
||||||
|
t.string "name", null: false
|
||||||
|
t.text "linked_tags", null: false
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.bigint "created_by_user_id"
|
||||||
|
t.index ["created_at"], name: "index_nico_tag_versions_on_created_at"
|
||||||
|
t.index ["created_by_user_id", "created_at"], name: "index_nico_tag_versions_on_created_by_user_id_and_created_at", order: { created_at: :desc }
|
||||||
|
t.index ["tag_id", "created_at"], name: "index_nico_tag_versions_on_tag_id_and_created_at", order: { created_at: :desc }
|
||||||
|
t.index ["tag_id", "version_no"], name: "index_nico_tag_versions_on_tag_id_and_version_no", unique: true
|
||||||
|
t.check_constraint "`version_no` > 0", name: "nico_tag_versions_version_no_positive"
|
||||||
|
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
|
||||||
@@ -93,19 +157,39 @@ ActiveRecord::Schema[8.0].define(version: 2026_03_17_015000) do
|
|||||||
t.index ["tag_id"], name: "index_post_tags_on_tag_id"
|
t.index ["tag_id"], name: "index_post_tags_on_tag_id"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
create_table "post_versions", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||||
|
t.bigint "post_id", null: false
|
||||||
|
t.integer "version_no", null: false
|
||||||
|
t.string "event_type", null: false
|
||||||
|
t.string "title"
|
||||||
|
t.string "url", limit: 768, null: false
|
||||||
|
t.string "thumbnail_base", limit: 2000
|
||||||
|
t.text "tags", null: false
|
||||||
|
t.text "parent_post_ids", null: false
|
||||||
|
t.datetime "original_created_from"
|
||||||
|
t.datetime "original_created_before"
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.bigint "created_by_user_id"
|
||||||
|
t.index ["created_by_user_id"], name: "index_post_versions_on_created_by_user_id"
|
||||||
|
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.check_constraint "`event_type` in (_utf8mb4'create',_utf8mb4'update',_utf8mb4'discard',_utf8mb4'restore')", name: "post_versions_event_type_valid"
|
||||||
|
t.check_constraint "`version_no` > 0", name: "post_versions_version_no_positive"
|
||||||
|
end
|
||||||
|
|
||||||
create_table "posts", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
create_table "posts", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||||
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.index ["parent_id"], name: "index_posts_on_parent_id"
|
t.integer "version_no", null: false
|
||||||
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|
|
||||||
@@ -156,6 +240,23 @@ ActiveRecord::Schema[8.0].define(version: 2026_03_17_015000) do
|
|||||||
t.index ["target_tag_id"], name: "index_tag_similarities_on_target_tag_id"
|
t.index ["target_tag_id"], name: "index_tag_similarities_on_target_tag_id"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
create_table "tag_versions", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||||
|
t.bigint "tag_id", null: false
|
||||||
|
t.integer "version_no", null: false
|
||||||
|
t.string "event_type", null: false
|
||||||
|
t.string "name", null: false
|
||||||
|
t.string "category", null: false
|
||||||
|
t.text "aliases", null: false
|
||||||
|
t.text "parent_tag_ids", null: false
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.bigint "created_by_user_id"
|
||||||
|
t.index ["created_at"], name: "index_tag_versions_on_created_at"
|
||||||
|
t.index ["created_by_user_id", "created_at"], name: "index_tag_versions_on_created_by_user_id_and_created_at", order: { created_at: :desc }
|
||||||
|
t.index ["tag_id", "created_at"], name: "index_tag_versions_on_tag_id_and_created_at", order: { created_at: :desc }
|
||||||
|
t.index ["tag_id", "version_no"], name: "index_tag_versions_on_tag_id_and_version_no", unique: true
|
||||||
|
t.check_constraint "`version_no` > 0", name: "tag_versions_version_no_positive"
|
||||||
|
end
|
||||||
|
|
||||||
create_table "tags", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
create_table "tags", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||||
t.bigint "tag_name_id", null: false
|
t.bigint "tag_name_id", null: false
|
||||||
t.string "category", default: "general", null: false
|
t.string "category", default: "general", null: false
|
||||||
@@ -163,8 +264,10 @@ ActiveRecord::Schema[8.0].define(version: 2026_03_17_015000) 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|
|
||||||
@@ -234,9 +337,23 @@ ActiveRecord::Schema[8.0].define(version: 2026_03_17_015000) 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.boolean "banned", default: false, null: false
|
t.datetime "banned_at"
|
||||||
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
|
||||||
|
|
||||||
|
create_table "wiki_assets", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||||
|
t.bigint "wiki_page_id", null: false
|
||||||
|
t.integer "no", null: false
|
||||||
|
t.string "alt_text"
|
||||||
|
t.binary "sha256", limit: 32, null: false
|
||||||
|
t.bigint "created_by_user_id", null: false
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.datetime "updated_at", null: false
|
||||||
|
t.index ["created_by_user_id"], name: "index_wiki_assets_on_created_by_user_id"
|
||||||
|
t.index ["wiki_page_id", "no"], name: "index_wiki_assets_on_wiki_page_id_and_no", unique: true
|
||||||
|
t.index ["wiki_page_id", "sha256"], name: "index_wiki_assets_on_wiki_page_id_and_sha256", unique: true
|
||||||
end
|
end
|
||||||
|
|
||||||
create_table "wiki_lines", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
create_table "wiki_lines", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||||
@@ -249,15 +366,19 @@ ActiveRecord::Schema[8.0].define(version: 2026_03_17_015000) do
|
|||||||
|
|
||||||
create_table "wiki_pages", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
create_table "wiki_pages", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||||
t.bigint "tag_name_id", null: false
|
t.bigint "tag_name_id", null: false
|
||||||
|
t.text "body", null: false
|
||||||
t.bigint "created_user_id", null: false
|
t.bigint "created_user_id", null: false
|
||||||
t.bigint "updated_user_id", null: false
|
t.bigint "updated_user_id", 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.datetime "discarded_at"
|
t.datetime "discarded_at"
|
||||||
|
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|
|
||||||
@@ -290,17 +411,47 @@ ActiveRecord::Schema[8.0].define(version: 2026_03_17_015000) do
|
|||||||
t.index ["wiki_page_id"], name: "index_wiki_revisions_on_wiki_page_id"
|
t.index ["wiki_page_id"], name: "index_wiki_revisions_on_wiki_page_id"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
create_table "wiki_versions", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||||
|
t.bigint "wiki_page_id", null: false
|
||||||
|
t.integer "version_no", null: false
|
||||||
|
t.string "event_type", null: false
|
||||||
|
t.string "title", null: false
|
||||||
|
t.text "body", null: false
|
||||||
|
t.text "reason"
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.bigint "created_by_user_id"
|
||||||
|
t.index ["created_by_user_id"], name: "index_wiki_versions_on_created_by_user_id"
|
||||||
|
t.index ["wiki_page_id", "version_no"], name: "index_wiki_versions_on_wiki_page_id_and_version_no", unique: true
|
||||||
|
t.index ["wiki_page_id"], name: "index_wiki_versions_on_wiki_page_id"
|
||||||
|
t.check_constraint "`event_type` in (_utf8mb4'create',_utf8mb4'update',_utf8mb4'discard',_utf8mb4'restore')", name: "wiki_versions_event_type_valid"
|
||||||
|
t.check_constraint "`version_no` > 0", name: "wiki_versions_version_no_positive"
|
||||||
|
end
|
||||||
|
|
||||||
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
|
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
|
||||||
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
|
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
|
||||||
|
add_foreign_key "material_versions", "materials"
|
||||||
|
add_foreign_key "material_versions", "materials", column: "parent_id"
|
||||||
|
add_foreign_key "material_versions", "tags"
|
||||||
|
add_foreign_key "material_versions", "users", column: "created_by_user_id"
|
||||||
|
add_foreign_key "material_versions", "users", column: "updated_by_user_id"
|
||||||
|
add_foreign_key "materials", "materials", column: "parent_id"
|
||||||
|
add_foreign_key "materials", "tags"
|
||||||
|
add_foreign_key "materials", "users", column: "created_by_user_id"
|
||||||
|
add_foreign_key "materials", "users", column: "updated_by_user_id"
|
||||||
add_foreign_key "nico_tag_relations", "tags"
|
add_foreign_key "nico_tag_relations", "tags"
|
||||||
add_foreign_key "nico_tag_relations", "tags", column: "nico_tag_id"
|
add_foreign_key "nico_tag_relations", "tags", column: "nico_tag_id"
|
||||||
|
add_foreign_key "nico_tag_versions", "tags"
|
||||||
|
add_foreign_key "nico_tag_versions", "users", column: "created_by_user_id"
|
||||||
|
add_foreign_key "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"
|
||||||
add_foreign_key "post_tags", "tags"
|
add_foreign_key "post_tags", "tags"
|
||||||
add_foreign_key "post_tags", "users", column: "created_user_id"
|
add_foreign_key "post_tags", "users", column: "created_user_id"
|
||||||
add_foreign_key "post_tags", "users", column: "deleted_user_id"
|
add_foreign_key "post_tags", "users", column: "deleted_user_id"
|
||||||
add_foreign_key "posts", "posts", column: "parent_id"
|
add_foreign_key "post_versions", "posts"
|
||||||
|
add_foreign_key "post_versions", "users", column: "created_by_user_id"
|
||||||
add_foreign_key "posts", "users", column: "uploaded_user_id"
|
add_foreign_key "posts", "users", column: "uploaded_user_id"
|
||||||
add_foreign_key "settings", "users"
|
add_foreign_key "settings", "users"
|
||||||
add_foreign_key "tag_implications", "tags"
|
add_foreign_key "tag_implications", "tags"
|
||||||
@@ -308,6 +459,8 @@ ActiveRecord::Schema[8.0].define(version: 2026_03_17_015000) do
|
|||||||
add_foreign_key "tag_names", "tag_names", column: "canonical_id"
|
add_foreign_key "tag_names", "tag_names", column: "canonical_id"
|
||||||
add_foreign_key "tag_similarities", "tags"
|
add_foreign_key "tag_similarities", "tags"
|
||||||
add_foreign_key "tag_similarities", "tags", column: "target_tag_id"
|
add_foreign_key "tag_similarities", "tags", column: "target_tag_id"
|
||||||
|
add_foreign_key "tag_versions", "tags"
|
||||||
|
add_foreign_key "tag_versions", "users", column: "created_by_user_id"
|
||||||
add_foreign_key "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"
|
||||||
@@ -320,6 +473,8 @@ ActiveRecord::Schema[8.0].define(version: 2026_03_17_015000) do
|
|||||||
add_foreign_key "user_ips", "users"
|
add_foreign_key "user_ips", "users"
|
||||||
add_foreign_key "user_post_views", "posts"
|
add_foreign_key "user_post_views", "posts"
|
||||||
add_foreign_key "user_post_views", "users"
|
add_foreign_key "user_post_views", "users"
|
||||||
|
add_foreign_key "wiki_assets", "users", column: "created_by_user_id"
|
||||||
|
add_foreign_key "wiki_assets", "wiki_pages"
|
||||||
add_foreign_key "wiki_pages", "tag_names"
|
add_foreign_key "wiki_pages", "tag_names"
|
||||||
add_foreign_key "wiki_pages", "users", column: "created_user_id"
|
add_foreign_key "wiki_pages", "users", column: "created_user_id"
|
||||||
add_foreign_key "wiki_pages", "users", column: "updated_user_id"
|
add_foreign_key "wiki_pages", "users", column: "updated_user_id"
|
||||||
@@ -329,4 +484,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_03_17_015000) do
|
|||||||
add_foreign_key "wiki_revisions", "wiki_pages"
|
add_foreign_key "wiki_revisions", "wiki_pages"
|
||||||
add_foreign_key "wiki_revisions", "wiki_pages", column: "redirect_page_id"
|
add_foreign_key "wiki_revisions", "wiki_pages", column: "redirect_page_id"
|
||||||
add_foreign_key "wiki_revisions", "wiki_revisions", column: "base_revision_id"
|
add_foreign_key "wiki_revisions", "wiki_revisions", column: "base_revision_id"
|
||||||
|
add_foreign_key "wiki_versions", "users", column: "created_by_user_id"
|
||||||
|
add_foreign_key "wiki_versions", "wiki_pages"
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
namespace :nico do
|
||||||
|
desc 'ニコニコ DB 逆連携'
|
||||||
|
task export: :environment do
|
||||||
|
require 'open3'
|
||||||
|
|
||||||
|
mysql_user = ENV.fetch('MYSQL_USER')
|
||||||
|
mysql_pass = ENV.fetch('MYSQL_PASS')
|
||||||
|
nizika_nico_path = ENV.fetch('NIZIKA_NICO_PATH')
|
||||||
|
|
||||||
|
videos = Post.where('url LIKE ?', '%nicovideo.jp/watch/%').pluck(:url).filter_map {
|
||||||
|
_1[%r{nicovideo\.jp/watch/([^/?#]+)}, 1]
|
||||||
|
}.uniq
|
||||||
|
|
||||||
|
next if videos.empty?
|
||||||
|
|
||||||
|
_, stderr, status = Open3.capture3(
|
||||||
|
{ 'MYSQL_USER' => mysql_user, 'MYSQL_PASS' => mysql_pass },
|
||||||
|
'python3', '-m', 'tracked_videos.put_bulk_upsert', *videos,
|
||||||
|
chdir: nizika_nico_path)
|
||||||
|
|
||||||
|
raise stderr unless status.success?
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -61,6 +61,9 @@ namespace :nico do
|
|||||||
original_created_from = original_created_at&.change(sec: 0)
|
original_created_from = original_created_at&.change(sec: 0)
|
||||||
original_created_before = original_created_from&.+(1.minute)
|
original_created_before = original_created_from&.+(1.minute)
|
||||||
|
|
||||||
|
post_created = false
|
||||||
|
post_changed = false
|
||||||
|
|
||||||
if post
|
if post
|
||||||
attrs = { title:, original_created_from:, original_created_before: }
|
attrs = { title:, original_created_from:, original_created_before: }
|
||||||
|
|
||||||
@@ -76,11 +79,13 @@ namespace :nico do
|
|||||||
end
|
end
|
||||||
|
|
||||||
post.assign_attributes(attrs)
|
post.assign_attributes(attrs)
|
||||||
if post.changed?
|
post_changed = post.changed?
|
||||||
|
if post_changed
|
||||||
post.save!
|
post.save!
|
||||||
post.resized_thumbnail! if post.thumbnail.attached?
|
post.resized_thumbnail! if post.thumbnail.attached?
|
||||||
end
|
end
|
||||||
else
|
else
|
||||||
|
post_created = true
|
||||||
url = "https://www.nicovideo.jp/watch/#{ code }"
|
url = "https://www.nicovideo.jp/watch/#{ code }"
|
||||||
thumbnail_base = fetch_thumbnail.(url) rescue nil
|
thumbnail_base = fetch_thumbnail.(url) rescue nil
|
||||||
post = Post.new(title:, url:, thumbnail_base:, uploaded_user: nil,
|
post = Post.new(title:, url:, thumbnail_base:, uploaded_user: nil,
|
||||||
@@ -110,6 +115,10 @@ namespace :nico do
|
|||||||
datum['tags'].each do |raw|
|
datum['tags'].each do |raw|
|
||||||
name = TagNameSanitisationRule.sanitise("nico:#{ raw }")
|
name = TagNameSanitisationRule.sanitise("nico:#{ raw }")
|
||||||
tag = Tag.find_or_create_by_tag_name!(name, category: :nico)
|
tag = Tag.find_or_create_by_tag_name!(name, category: :nico)
|
||||||
|
|
||||||
|
event_type = tag.nico_tag_versions.exists? ? :update : :create
|
||||||
|
NicoTagVersionRecorder.record!(tag:, event_type:, created_by_user: nil)
|
||||||
|
|
||||||
desired_nico_tag_based_ids << tag.id
|
desired_nico_tag_based_ids << tag.id
|
||||||
|
|
||||||
# 新たに記載される外部タグと連携される内部タグを記載
|
# 新たに記載される外部タグと連携される内部タグを記載
|
||||||
@@ -140,6 +149,13 @@ namespace :nico do
|
|||||||
desired_all_tag_ids.uniq!
|
desired_all_tag_ids.uniq!
|
||||||
|
|
||||||
sync_post_tags!(post, desired_all_tag_ids, current_tag_ids: kept_tag_ids)
|
sync_post_tags!(post, desired_all_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_all_tag_ids.to_set
|
||||||
|
PostVersionRecorder.ensure_snapshot!(post, created_by_user: nil)
|
||||||
|
PostVersionRecorder.record!(post:, event_type: :update, created_by_user: nil)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace :post do
|
||||||
|
desc '投稿同期(ニコニコ以外)'
|
||||||
|
task sync: :environment do
|
||||||
|
Youtube::Sync.new.sync!
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
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
|
||||||
@@ -1,15 +1,24 @@
|
|||||||
FactoryBot.define do
|
FactoryBot.define do
|
||||||
factory :user do
|
factory :user do
|
||||||
name { "test-user" }
|
name { nil }
|
||||||
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
|
||||||
|
|||||||
@@ -3,5 +3,7 @@ FactoryBot.define do
|
|||||||
title { "TestPage" }
|
title { "TestPage" }
|
||||||
association :created_user, factory: :user
|
association :created_user, factory: :user
|
||||||
association :updated_user, factory: :user
|
association :updated_user, factory: :user
|
||||||
|
|
||||||
|
body { ' ' }
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
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
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe PostVersion, type: :model do
|
||||||
|
let!(:tag_name) { TagName.create!(name: 'post_version_spec_tag') }
|
||||||
|
let!(:tag) { Tag.create!(tag_name: tag_name, category: :general) }
|
||||||
|
|
||||||
|
let!(:post_record) do
|
||||||
|
Post.create!(title: 'spec post', url: 'https://example.com/post-version-spec').tap do |post|
|
||||||
|
PostTag.create!(post: post, tag: tag)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
let!(:post_version) do
|
||||||
|
PostVersion.create!(
|
||||||
|
post: post_record,
|
||||||
|
version_no: 1,
|
||||||
|
event_type: 'create',
|
||||||
|
title: post_record.title,
|
||||||
|
url: post_record.url,
|
||||||
|
thumbnail_base: post_record.thumbnail_base,
|
||||||
|
tags: post_record.snapshot_tag_names.join(' '),
|
||||||
|
parent_post_ids: post_record.snapshot_parent_post_ids.join(' '),
|
||||||
|
original_created_from: post_record.original_created_from,
|
||||||
|
original_created_before: post_record.original_created_before,
|
||||||
|
created_at: Time.current,
|
||||||
|
created_by_user: nil
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'is read only after create' do
|
||||||
|
expect do
|
||||||
|
post_version.update!(title: 'changed')
|
||||||
|
end.to raise_error(ActiveRecord::ReadOnlyRecord)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'cannot be destroyed' do
|
||||||
|
expect do
|
||||||
|
post_version.destroy!
|
||||||
|
end.to raise_error(ActiveRecord::ReadOnlyRecord)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -107,11 +107,13 @@ RSpec.describe Tag, type: :model do
|
|||||||
context 'when the source tag_name has a wiki_page' do
|
context 'when the source tag_name has a wiki_page' do
|
||||||
let!(:source_post_tag) { PostTag.create!(post: post_record, tag: source_tag) }
|
let!(:source_post_tag) { PostTag.create!(post: post_record, tag: source_tag) }
|
||||||
let!(:wiki_page) do
|
let!(:wiki_page) do
|
||||||
WikiPage.create!(
|
admin = create_admin_user!
|
||||||
tag_name: source_tag_name,
|
|
||||||
created_user: create_admin_user!,
|
Wiki::Commit.create_content!(
|
||||||
updated_user: create_admin_user!
|
tag_name: source_tag_name,
|
||||||
)
|
body: 'source wiki body',
|
||||||
|
created_by_user: admin,
|
||||||
|
message: 'init')
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'rolls back the transaction' do
|
it 'rolls back the transaction' do
|
||||||
@@ -145,5 +147,70 @@ RSpec.describe Tag, type: :model do
|
|||||||
expect(target_tag.reload.post_count).to eq(0)
|
expect(target_tag.reload.post_count).to eq(0)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def snapshot_tags(post)
|
||||||
|
post.snapshot_tag_names.join(' ')
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_post_version_for!(post, version_no: 1, event_type: 'create', created_by_user: nil)
|
||||||
|
PostVersion.create!(
|
||||||
|
post: post,
|
||||||
|
version_no: version_no,
|
||||||
|
event_type: event_type,
|
||||||
|
title: post.title,
|
||||||
|
url: post.url,
|
||||||
|
thumbnail_base: post.thumbnail_base,
|
||||||
|
tags: snapshot_tags(post),
|
||||||
|
parent_post_ids: post.snapshot_parent_post_ids.join(' '),
|
||||||
|
original_created_from: post.original_created_from,
|
||||||
|
original_created_before: post.original_created_before,
|
||||||
|
created_at: Time.current,
|
||||||
|
created_by_user: created_by_user
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when post versions are enabled' do
|
||||||
|
let!(:source_post_tag) { PostTag.create!(post: post_record, tag: source_tag) }
|
||||||
|
let!(:unaffected_post) do
|
||||||
|
Post.create!(url: 'https://example.com/posts/2', title: 'unaffected post')
|
||||||
|
end
|
||||||
|
|
||||||
|
before do
|
||||||
|
create_post_version_for!(post_record)
|
||||||
|
create_post_version_for!(unaffected_post)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'creates an update post_version only for affected posts' do
|
||||||
|
expect {
|
||||||
|
described_class.merge_tags!(target_tag, [source_tag])
|
||||||
|
}.to change(PostVersion, :count).by(1)
|
||||||
|
|
||||||
|
affected_versions = post_record.reload.post_versions.order(:version_no)
|
||||||
|
expect(affected_versions.pluck(:version_no)).to eq([1, 2])
|
||||||
|
|
||||||
|
latest = affected_versions.last
|
||||||
|
expect(latest.event_type).to eq('update')
|
||||||
|
expect(latest.created_by_user).to be_nil
|
||||||
|
expect(latest.tags).to eq(snapshot_tags(post_record.reload))
|
||||||
|
|
||||||
|
expect(unaffected_post.reload.post_versions.count).to eq(1)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when the source tag has no active post_tags' do
|
||||||
|
let!(:another_post) do
|
||||||
|
Post.create!(url: 'https://example.com/posts/3', title: 'another post')
|
||||||
|
end
|
||||||
|
|
||||||
|
before do
|
||||||
|
create_post_version_for!(another_post)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'does not create any post_version' do
|
||||||
|
expect {
|
||||||
|
described_class.merge_tags!(target_tag, [source_tag])
|
||||||
|
}.not_to change(PostVersion, :count)
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe VersionRecord, type: :model do
|
||||||
|
let!(:tag) { create(:tag, name: 'version_record_tag') }
|
||||||
|
let!(:nico_tag) { create(:tag, :nico, name: 'nico:version_record_tag') }
|
||||||
|
|
||||||
|
it 'makes TagVersion read only after create' do
|
||||||
|
version = TagVersion.create!(
|
||||||
|
tag: tag,
|
||||||
|
version_no: 1,
|
||||||
|
event_type: 'create',
|
||||||
|
name: tag.name,
|
||||||
|
category: tag.category,
|
||||||
|
aliases: '',
|
||||||
|
parent_tag_ids: '',
|
||||||
|
created_at: Time.current,
|
||||||
|
created_by_user: nil
|
||||||
|
)
|
||||||
|
|
||||||
|
expect {
|
||||||
|
version.update!(name: 'changed')
|
||||||
|
}.to raise_error(ActiveRecord::ReadOnlyRecord)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'prevents TagVersion destroy' do
|
||||||
|
version = TagVersion.create!(
|
||||||
|
tag: tag,
|
||||||
|
version_no: 1,
|
||||||
|
event_type: 'create',
|
||||||
|
name: tag.name,
|
||||||
|
category: tag.category,
|
||||||
|
aliases: '',
|
||||||
|
parent_tag_ids: '',
|
||||||
|
created_at: Time.current,
|
||||||
|
created_by_user: nil
|
||||||
|
)
|
||||||
|
|
||||||
|
expect {
|
||||||
|
version.destroy!
|
||||||
|
}.to raise_error(ActiveRecord::ReadOnlyRecord)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'makes NicoTagVersion read only after create' do
|
||||||
|
version = NicoTagVersion.create!(
|
||||||
|
tag: nico_tag,
|
||||||
|
version_no: 1,
|
||||||
|
event_type: 'create',
|
||||||
|
name: nico_tag.name,
|
||||||
|
linked_tags: '',
|
||||||
|
created_at: Time.current,
|
||||||
|
created_by_user: nil
|
||||||
|
)
|
||||||
|
|
||||||
|
expect {
|
||||||
|
version.update!(name: 'nico:changed')
|
||||||
|
}.to raise_error(ActiveRecord::ReadOnlyRecord)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'prevents NicoTagVersion destroy' do
|
||||||
|
version = NicoTagVersion.create!(
|
||||||
|
tag: nico_tag,
|
||||||
|
version_no: 1,
|
||||||
|
event_type: 'create',
|
||||||
|
name: nico_tag.name,
|
||||||
|
linked_tags: '',
|
||||||
|
created_at: Time.current,
|
||||||
|
created_by_user: nil
|
||||||
|
)
|
||||||
|
|
||||||
|
expect {
|
||||||
|
version.destroy!
|
||||||
|
}.to raise_error(ActiveRecord::ReadOnlyRecord)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,378 @@
|
|||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe 'Materials API', type: :request do
|
||||||
|
let!(:member_user) { create(:user, :member) }
|
||||||
|
let!(:guest_user) { create(:user) }
|
||||||
|
|
||||||
|
def dummy_upload(filename: 'dummy.png', type: 'image/png', body: 'dummy')
|
||||||
|
Rack::Test::UploadedFile.new(StringIO.new(body), type, original_filename: filename)
|
||||||
|
end
|
||||||
|
|
||||||
|
def response_materials
|
||||||
|
json.fetch('materials')
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_material(tag:, user:, parent: nil, file: dummy_upload, url: nil)
|
||||||
|
Material.new(tag:, parent:, url:, created_by_user: user, updated_by_user: user).tap do |material|
|
||||||
|
material.file.attach(file) if file
|
||||||
|
material.save!
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'GET /materials' do
|
||||||
|
let!(:tag_a) { Tag.create!(tag_name: TagName.create!(name: 'material_index_a'), category: :material) }
|
||||||
|
let!(:tag_b) { Tag.create!(tag_name: TagName.create!(name: 'material_index_b'), category: :material) }
|
||||||
|
|
||||||
|
let!(:material_a) do
|
||||||
|
build_material(tag: tag_a, user: member_user, file: dummy_upload(filename: 'a.png'))
|
||||||
|
end
|
||||||
|
|
||||||
|
let!(:material_b) do
|
||||||
|
build_material(tag: tag_b, user: member_user, parent: material_a, file: dummy_upload(filename: 'b.png'))
|
||||||
|
end
|
||||||
|
|
||||||
|
before do
|
||||||
|
old_time = Time.zone.local(2026, 3, 29, 1, 0, 0)
|
||||||
|
new_time = Time.zone.local(2026, 3, 29, 2, 0, 0)
|
||||||
|
|
||||||
|
material_a.update_columns(created_at: old_time, updated_at: old_time)
|
||||||
|
material_b.update_columns(created_at: new_time, updated_at: new_time)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns materials with count and metadata' do
|
||||||
|
get '/materials'
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(json).to include('materials', 'count')
|
||||||
|
expect(response_materials).to be_an(Array)
|
||||||
|
expect(json['count']).to eq(2)
|
||||||
|
|
||||||
|
row = response_materials.find { |m| m['id'] == material_b.id }
|
||||||
|
expect(row).to be_present
|
||||||
|
expect(row['tag']).to include(
|
||||||
|
'id' => tag_b.id,
|
||||||
|
'name' => 'material_index_b',
|
||||||
|
'category' => 'material'
|
||||||
|
)
|
||||||
|
expect(row['created_by_user']).to include(
|
||||||
|
'id' => member_user.id,
|
||||||
|
'name' => member_user.name
|
||||||
|
)
|
||||||
|
expect(row['content_type']).to eq('image/png')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'filters materials by tag_id' do
|
||||||
|
get '/materials', params: { tag_id: material_a.tag_id }
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(json['count']).to eq(1)
|
||||||
|
expect(response_materials.map { |m| m['id'] }).to eq([material_a.id])
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'filters materials by parent_id' do
|
||||||
|
get '/materials', params: { parent_id: material_a.id }
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(json['count']).to eq(1)
|
||||||
|
expect(response_materials.map { |m| m['id'] }).to eq([material_b.id])
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'paginates and keeps total count' do
|
||||||
|
get '/materials', params: { page: 2, limit: 1 }
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(json['count']).to eq(2)
|
||||||
|
expect(response_materials.size).to eq(1)
|
||||||
|
expect(response_materials.first['id']).to eq(material_a.id)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'normalises invalid page and limit' do
|
||||||
|
get '/materials', params: { page: 0, limit: 0 }
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(json['count']).to eq(2)
|
||||||
|
expect(response_materials.size).to eq(1)
|
||||||
|
expect(response_materials.first['id']).to eq(material_b.id)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'GET /materials/:id' do
|
||||||
|
let!(:tag) { Tag.create!(tag_name: TagName.create!(name: 'material_show'), category: :material) }
|
||||||
|
let!(:material) do
|
||||||
|
build_material(tag:, user: member_user, file: dummy_upload(filename: 'show.png'))
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns a material with file, tag, and content_type' do
|
||||||
|
get "/materials/#{ material.id }"
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(json).to include(
|
||||||
|
'id' => material.id,
|
||||||
|
'content_type' => 'image/png'
|
||||||
|
)
|
||||||
|
expect(json['file']).to be_present
|
||||||
|
expect(json['tag']).to include(
|
||||||
|
'id' => tag.id,
|
||||||
|
'name' => 'material_show',
|
||||||
|
'category' => 'material'
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns 404 when material does not exist' do
|
||||||
|
get '/materials/999999999'
|
||||||
|
expect(response).to have_http_status(:not_found)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'POST /materials' do
|
||||||
|
context 'when not logged in' do
|
||||||
|
before { sign_out }
|
||||||
|
|
||||||
|
it 'returns 401' do
|
||||||
|
post '/materials', params: {
|
||||||
|
tag: 'material_create_unauthorized',
|
||||||
|
file: dummy_upload
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:unauthorized)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when logged in' do
|
||||||
|
before { sign_in_as(guest_user) }
|
||||||
|
|
||||||
|
it 'returns 400 when tag is blank' do
|
||||||
|
post '/materials', params: { tag: ' ', file: dummy_upload }
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:bad_request)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns 400 when both file and url are blank' do
|
||||||
|
post '/materials', params: { tag: 'material_create_blank' }
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:bad_request)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'creates a material with an attached file' do
|
||||||
|
expect do
|
||||||
|
post '/materials', params: {
|
||||||
|
tag: 'material_create_new',
|
||||||
|
file: dummy_upload(filename: 'created.png')
|
||||||
|
}
|
||||||
|
end.to change(Material, :count).by(1)
|
||||||
|
.and change(Tag, :count).by(1)
|
||||||
|
.and change(TagName, :count).by(1)
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:created)
|
||||||
|
|
||||||
|
material = Material.order(:id).last
|
||||||
|
expect(material.tag.name).to eq('material_create_new')
|
||||||
|
expect(material.tag.category).to eq('material')
|
||||||
|
expect(material.created_by_user).to eq(guest_user)
|
||||||
|
expect(material.updated_by_user).to eq(guest_user)
|
||||||
|
expect(material.file.attached?).to be(true)
|
||||||
|
|
||||||
|
expect(json['id']).to eq(material.id)
|
||||||
|
expect(json.dig('tag', 'name')).to eq('material_create_new')
|
||||||
|
expect(json['content_type']).to eq('image/png')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns 422 when the existing tag is not material/character' do
|
||||||
|
general_tag_name = TagName.create!(name: 'material_create_general_tag')
|
||||||
|
Tag.create!(tag_name: general_tag_name, category: :general)
|
||||||
|
|
||||||
|
post '/materials', params: {
|
||||||
|
tag: 'material_create_general_tag',
|
||||||
|
file: dummy_upload
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:unprocessable_entity)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'persists url-only material' do
|
||||||
|
expect do
|
||||||
|
post '/materials', params: {
|
||||||
|
tag: 'material_create_url_only',
|
||||||
|
url: 'https://example.com/material-source'
|
||||||
|
}
|
||||||
|
end.to change(Material, :count).by(1)
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:created)
|
||||||
|
|
||||||
|
material = Material.order(:id).last
|
||||||
|
expect(material.tag.name).to eq('material_create_url_only')
|
||||||
|
expect(material.url).to eq('https://example.com/material-source')
|
||||||
|
expect(material.file.attached?).to be(false)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns the original url for url-only material' do
|
||||||
|
post '/materials', params: {
|
||||||
|
tag: 'material_create_url_only_response',
|
||||||
|
url: 'https://example.com/material-source'
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:created)
|
||||||
|
expect(json['url']).to eq('https://example.com/material-source')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'PUT /materials/:id' do
|
||||||
|
let!(:tag) { Tag.create!(tag_name: TagName.create!(name: 'material_update_old'), category: :material) }
|
||||||
|
let!(:material) do
|
||||||
|
build_material(tag:, user: member_user, file: dummy_upload(filename: 'old.png'))
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when not logged in' do
|
||||||
|
before { sign_out }
|
||||||
|
|
||||||
|
it 'returns 401' do
|
||||||
|
put "/materials/#{ material.id }", params: {
|
||||||
|
tag: 'material_update_new',
|
||||||
|
file: dummy_upload(filename: 'new.png')
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:unauthorized)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when logged in but not member' do
|
||||||
|
before { sign_in_as(guest_user) }
|
||||||
|
|
||||||
|
it 'returns 403' do
|
||||||
|
put "/materials/#{ material.id }", params: {
|
||||||
|
tag: 'material_update_new',
|
||||||
|
file: dummy_upload(filename: 'new.png')
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:forbidden)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when member' do
|
||||||
|
before { sign_in_as(member_user) }
|
||||||
|
|
||||||
|
it 'returns 404 when material does not exist' do
|
||||||
|
put '/materials/999999999', params: {
|
||||||
|
tag: 'material_update_missing',
|
||||||
|
file: dummy_upload
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:not_found)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns 400 when tag is blank' do
|
||||||
|
put "/materials/#{ material.id }", params: {
|
||||||
|
tag: ' ',
|
||||||
|
file: dummy_upload
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:bad_request)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns 400 when both file and url are blank' do
|
||||||
|
put "/materials/#{ material.id }", params: {
|
||||||
|
tag: 'material_update_no_payload'
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:bad_request)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'updates tag, url, file, and updated_by_user' do
|
||||||
|
old_blob_id = material.file.blob.id
|
||||||
|
|
||||||
|
put "/materials/#{ material.id }", params: {
|
||||||
|
tag: 'material_update_new',
|
||||||
|
url: 'https://example.com/updated-source',
|
||||||
|
file: dummy_upload(filename: 'updated.jpg', type: 'image/jpeg')
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
|
||||||
|
material.reload
|
||||||
|
expect(material.tag.name).to eq('material_update_new')
|
||||||
|
expect(material.tag.category).to eq('material')
|
||||||
|
expect(material.url).to eq('https://example.com/updated-source')
|
||||||
|
expect(material.updated_by_user).to eq(member_user)
|
||||||
|
expect(material.file.attached?).to be(true)
|
||||||
|
expect(material.file.blob.id).not_to eq(old_blob_id)
|
||||||
|
expect(material.file.blob.filename.to_s).to eq('updated.jpg')
|
||||||
|
expect(material.file.blob.content_type).to eq('image/jpeg')
|
||||||
|
|
||||||
|
expect(json['id']).to eq(material.id)
|
||||||
|
expect(json['file']).to be_present
|
||||||
|
expect(json['content_type']).to eq('image/jpeg')
|
||||||
|
expect(json.dig('tag', 'name')).to eq('material_update_new')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'purges the existing file when file is omitted and url is provided' do
|
||||||
|
old_blob_id = material.file.blob.id
|
||||||
|
|
||||||
|
put "/materials/#{ material.id }", params: {
|
||||||
|
tag: 'material_update_remove_file',
|
||||||
|
url: 'https://example.com/updated-source'
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
|
||||||
|
material.reload
|
||||||
|
expect(material.tag.name).to eq('material_update_remove_file')
|
||||||
|
expect(material.url).to eq('https://example.com/updated-source')
|
||||||
|
expect(material.updated_by_user).to eq(member_user)
|
||||||
|
expect(material.file.attached?).to be(false)
|
||||||
|
|
||||||
|
expect(
|
||||||
|
ActiveStorage::Blob.where(id: old_blob_id).exists?
|
||||||
|
).to be(false)
|
||||||
|
|
||||||
|
expect(json['id']).to eq(material.id)
|
||||||
|
expect(json['file']).to be_nil
|
||||||
|
expect(json['content_type']).to be_nil
|
||||||
|
expect(json.dig('tag', 'name')).to eq('material_update_remove_file')
|
||||||
|
expect(json['url']).to eq('https://example.com/updated-source')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'DELETE /materials/:id' do
|
||||||
|
let!(:tag) { Tag.create!(tag_name: TagName.create!(name: 'material_destroy'), category: :material) }
|
||||||
|
let!(:material) do
|
||||||
|
build_material(tag:, user: member_user, file: dummy_upload(filename: 'destroy.png'))
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when not logged in' do
|
||||||
|
before { sign_out }
|
||||||
|
|
||||||
|
it 'returns 401' do
|
||||||
|
delete "/materials/#{ material.id }"
|
||||||
|
expect(response).to have_http_status(:unauthorized)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when logged in but not member' do
|
||||||
|
before { sign_in_as(guest_user) }
|
||||||
|
|
||||||
|
it 'returns 403' do
|
||||||
|
delete "/materials/#{ material.id }"
|
||||||
|
expect(response).to have_http_status(:forbidden)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when member' do
|
||||||
|
before { sign_in_as(member_user) }
|
||||||
|
|
||||||
|
it 'returns 404 when material does not exist' do
|
||||||
|
delete '/materials/999999999'
|
||||||
|
expect(response).to have_http_status(:not_found)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'discards the material and returns 204' do
|
||||||
|
delete "/materials/#{ material.id }"
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:no_content)
|
||||||
|
expect(Material.find_by(id: material.id)).to be_nil
|
||||||
|
expect(Material.with_discarded.find(material.id)).to be_discarded
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -14,6 +14,7 @@ RSpec.describe 'NicoTags', type: :request do
|
|||||||
|
|
||||||
describe 'PATCH /tags/nico/:id' do
|
describe 'PATCH /tags/nico/:id' do
|
||||||
let(:member) { create(:user, :member) }
|
let(:member) { create(:user, :member) }
|
||||||
|
let(:admin) { create(:user, :admin) }
|
||||||
let(:nico_tag) { create(:tag, :nico) }
|
let(:nico_tag) { create(:tag, :nico) }
|
||||||
|
|
||||||
it '401 when not logged in' do
|
it '401 when not logged in' do
|
||||||
@@ -34,5 +35,59 @@ RSpec.describe 'NicoTags', type: :request do
|
|||||||
patch "/tags/nico/#{non_nico.id}", params: { tags: 'a b' }
|
patch "/tags/nico/#{non_nico.id}", params: { tags: 'a b' }
|
||||||
expect(response).to have_http_status(:bad_request)
|
expect(response).to have_http_status(:bad_request)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it '200 and updates linked tags while recording tag versions' do
|
||||||
|
sign_in_as(admin)
|
||||||
|
|
||||||
|
nico_tag_name = TagName.create!(name: 'nico:nico_tags_spec_source')
|
||||||
|
nico_tag = Tag.create!(tag_name: nico_tag_name, category: :nico)
|
||||||
|
|
||||||
|
linked_a_name = TagName.create!(name: 'nico_linked_a')
|
||||||
|
linked_a = Tag.create!(tag_name: linked_a_name, category: :general)
|
||||||
|
|
||||||
|
linked_b_name = TagName.create!(name: 'nico_linked_b')
|
||||||
|
linked_b = Tag.create!(tag_name: linked_b_name, category: :general)
|
||||||
|
|
||||||
|
TagVersioning.ensure_snapshot!(nico_tag, created_by_user: admin)
|
||||||
|
|
||||||
|
expect {
|
||||||
|
patch "/tags/nico/#{nico_tag.id}", params: {
|
||||||
|
tags: " #{linked_a.name}\n#{linked_b.name} "
|
||||||
|
}
|
||||||
|
}.to change(TagVersion, :count).by(2)
|
||||||
|
.and change(NicoTagVersion, :count).by(1)
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
|
||||||
|
names = json.map { |t| t['name'] }
|
||||||
|
expect(names).to match_array(['nico_linked_a', 'nico_linked_b'])
|
||||||
|
|
||||||
|
linked_versions = TagVersion.where(tag: [linked_a, linked_b]).order(:tag_id)
|
||||||
|
expect(linked_versions.map(&:event_type)).to eq(['create', 'create'])
|
||||||
|
expect(linked_versions.map(&:created_by_user_id)).to all(eq(admin.id))
|
||||||
|
|
||||||
|
versions = nico_tag.reload.nico_tag_versions.order(:version_no)
|
||||||
|
expect(versions.map(&:event_type)).to eq(['create', 'update'])
|
||||||
|
expect(versions.last.linked_tags.split).to match_array([
|
||||||
|
'nico_linked_a',
|
||||||
|
'nico_linked_b'
|
||||||
|
])
|
||||||
|
expect(versions.last.created_by_user_id).to eq(admin.id)
|
||||||
|
end
|
||||||
|
|
||||||
|
it '400 when linked tag normalises to nico tag' do
|
||||||
|
sign_in_as(member)
|
||||||
|
|
||||||
|
other_nico = create(:tag, :nico, name: 'nico:linked_ng')
|
||||||
|
TagName.create!(name: 'linked_ng_alias', canonical: other_nico.tag_name)
|
||||||
|
|
||||||
|
TagVersioning.ensure_snapshot!(nico_tag, created_by_user: member)
|
||||||
|
|
||||||
|
expect {
|
||||||
|
patch "/tags/nico/#{nico_tag.id}", params: { tags: 'linked_ng_alias' }
|
||||||
|
}.not_to change(NicoTagVersion, :count)
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:bad_request)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
+1007
-32
File diff suppressed because it is too large
Load Diff
@@ -58,15 +58,47 @@ RSpec.describe "TagChildren", type: :request do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
context "when Tag.find raises (invalid ids) it still returns 204" do
|
context "when Tag.find raises (invalid ids)" do
|
||||||
before { stub_current_user(admin) }
|
before { stub_current_user(admin) }
|
||||||
|
|
||||||
let(:parent_id) { -1 }
|
let(:parent_id) { -1 }
|
||||||
let(:child_id) { -1 }
|
let(:child_id) { -1 }
|
||||||
|
|
||||||
it "returns 204 (rescue nil)" do
|
it "returns 404" do
|
||||||
do_request
|
do_request
|
||||||
expect(response).to have_http_status(:no_content)
|
expect(response).to have_http_status(:not_found)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when parent is nico' do
|
||||||
|
before { stub_current_user(admin) }
|
||||||
|
|
||||||
|
let!(:parent) { create(:tag, :nico, name: 'nico:parent_ng') }
|
||||||
|
let(:parent_id) { parent.id }
|
||||||
|
let(:child_id) { child.id }
|
||||||
|
|
||||||
|
it 'returns 400 and does not create relation' do
|
||||||
|
expect {
|
||||||
|
do_request
|
||||||
|
}.not_to change(TagImplication, :count)
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:bad_request)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when child is nico' do
|
||||||
|
before { stub_current_user(admin) }
|
||||||
|
|
||||||
|
let!(:child) { create(:tag, :nico, name: 'nico:child_ng') }
|
||||||
|
let(:parent_id) { parent.id }
|
||||||
|
let(:child_id) { child.id }
|
||||||
|
|
||||||
|
it 'returns 400 and does not create relation' do
|
||||||
|
expect {
|
||||||
|
do_request
|
||||||
|
}.not_to change(TagImplication, :count)
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:bad_request)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -116,17 +148,57 @@ RSpec.describe "TagChildren", type: :request do
|
|||||||
|
|
||||||
expect(response).to have_http_status(:no_content)
|
expect(response).to have_http_status(:no_content)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'records create and update versions for child tag' do
|
||||||
|
expect {
|
||||||
|
do_request
|
||||||
|
}.to change(TagVersion, :count).by(2)
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:no_content)
|
||||||
|
|
||||||
|
versions = child.reload.tag_versions.order(:version_no)
|
||||||
|
expect(versions.map(&:event_type)).to eq(['create', 'update'])
|
||||||
|
expect(versions.first.parent_tag_ids.split).to include(parent.id.to_s)
|
||||||
|
expect(versions.second.parent_tag_ids).to eq('')
|
||||||
|
expect(versions.second.created_by_user_id).to eq(admin.id)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
context "when Tag.find raises (invalid ids) it still returns 204" do
|
context "when Tag.find raises (invalid ids)" do
|
||||||
before { stub_current_user(admin) }
|
before { stub_current_user(admin) }
|
||||||
|
|
||||||
let(:parent_id) { -1 }
|
let(:parent_id) { -1 }
|
||||||
let(:child_id) { -1 }
|
let(:child_id) { -1 }
|
||||||
|
|
||||||
it "returns 204 (rescue nil)" do
|
it "returns 404" do
|
||||||
do_request
|
do_request
|
||||||
expect(response).to have_http_status(:no_content)
|
expect(response).to have_http_status(:not_found)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when parent is nico' do
|
||||||
|
before { stub_current_user(admin) }
|
||||||
|
|
||||||
|
let!(:parent) { create(:tag, :nico, name: 'nico:parent_ng_delete') }
|
||||||
|
let(:parent_id) { parent.id }
|
||||||
|
let(:child_id) { child.id }
|
||||||
|
|
||||||
|
it 'returns 400' do
|
||||||
|
do_request
|
||||||
|
expect(response).to have_http_status(:bad_request)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when child is nico' do
|
||||||
|
before { stub_current_user(admin) }
|
||||||
|
|
||||||
|
let!(:child) { create(:tag, :nico, name: 'nico:child_ng_delete') }
|
||||||
|
let(:parent_id) { parent.id }
|
||||||
|
let(:child_id) { child.id }
|
||||||
|
|
||||||
|
it 'returns 400' do
|
||||||
|
do_request
|
||||||
|
expect(response).to have_http_status(:bad_request)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,248 @@
|
|||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe 'TagVersions API', type: :request do
|
||||||
|
let(:member) { create(:user, :member, name: 'version member') }
|
||||||
|
|
||||||
|
let!(:tag) { create(:tag, name: 'tag_versions_target', category: :general) }
|
||||||
|
let!(:other_tag) { create(:tag, name: 'tag_versions_other', category: :general) }
|
||||||
|
|
||||||
|
let!(:parent_shared) { create(:tag, name: 'parent_shared', category: :general) }
|
||||||
|
let!(:parent_old) { create(:tag, name: 'parent_old', category: :general) }
|
||||||
|
let!(:parent_new) { create(:tag, name: 'parent_new', category: :general) }
|
||||||
|
let!(:other_parent) { create(:tag, name: 'other_parent', category: :general) }
|
||||||
|
|
||||||
|
let(:t_v1) { Time.zone.local(2020, 1, 1, 12, 0, 0) }
|
||||||
|
let(:t_v2) { Time.zone.local(2020, 1, 2, 12, 0, 0) }
|
||||||
|
let(:t_other) { Time.zone.local(2020, 1, 3, 12, 0, 0) }
|
||||||
|
|
||||||
|
def create_tag_version!(
|
||||||
|
tag:,
|
||||||
|
version_no:,
|
||||||
|
event_type:,
|
||||||
|
name:,
|
||||||
|
category:,
|
||||||
|
aliases: [],
|
||||||
|
parent_tags: [],
|
||||||
|
created_by_user:,
|
||||||
|
created_at:
|
||||||
|
)
|
||||||
|
version =
|
||||||
|
TagVersion.create!(
|
||||||
|
tag: tag,
|
||||||
|
version_no: version_no,
|
||||||
|
event_type: event_type,
|
||||||
|
name: name,
|
||||||
|
category: category,
|
||||||
|
aliases: Array(aliases).join(' '),
|
||||||
|
parent_tag_ids: Array(parent_tags).map(&:id).join(' '),
|
||||||
|
created_by_user: created_by_user,
|
||||||
|
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
|
||||||
|
|
||||||
|
let!(:v1) do
|
||||||
|
create_tag_version!(
|
||||||
|
tag: tag,
|
||||||
|
version_no: 1,
|
||||||
|
event_type: 'create',
|
||||||
|
name: 'old_tag_name',
|
||||||
|
category: 'general',
|
||||||
|
aliases: ['alias_shared', 'alias_old'],
|
||||||
|
parent_tags: [parent_shared, parent_old],
|
||||||
|
created_by_user: member,
|
||||||
|
created_at: t_v1
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
let!(:v2) do
|
||||||
|
create_tag_version!(
|
||||||
|
tag: tag,
|
||||||
|
version_no: 2,
|
||||||
|
event_type: 'update',
|
||||||
|
name: 'new_tag_name',
|
||||||
|
category: 'meme',
|
||||||
|
aliases: ['alias_shared', 'alias_new'],
|
||||||
|
parent_tags: [parent_shared, parent_new],
|
||||||
|
created_by_user: member,
|
||||||
|
created_at: t_v2
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
let!(:other_v1) do
|
||||||
|
create_tag_version!(
|
||||||
|
tag: other_tag,
|
||||||
|
version_no: 1,
|
||||||
|
event_type: 'create',
|
||||||
|
name: 'other_tag_name',
|
||||||
|
category: 'general',
|
||||||
|
aliases: ['other_alias'],
|
||||||
|
parent_tags: [other_parent],
|
||||||
|
created_by_user: member,
|
||||||
|
created_at: t_other
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'GET /tags/versions' do
|
||||||
|
it 'returns all versions in reverse chronological order when id is omitted' do
|
||||||
|
get '/tags/versions'
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(json).to include('versions', 'count')
|
||||||
|
expect(json.fetch('count')).to eq(3)
|
||||||
|
|
||||||
|
versions = json.fetch('versions')
|
||||||
|
|
||||||
|
expect(versions.map { |v| [v['tag_id'], v['version_no']] }).to eq([
|
||||||
|
[other_tag.id, 1],
|
||||||
|
[tag.id, 2],
|
||||||
|
[tag.id, 1]
|
||||||
|
])
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns versions for the specified tag with diffs' do
|
||||||
|
get '/tags/versions', params: { id: tag.id }
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(json).to include('versions', 'count')
|
||||||
|
expect(json.fetch('count')).to eq(2)
|
||||||
|
|
||||||
|
versions = json.fetch('versions')
|
||||||
|
expect(versions.map { |v| v['tag_id'] }.uniq).to eq([tag.id])
|
||||||
|
expect(versions.map { |v| v['version_no'] }).to eq([2, 1])
|
||||||
|
|
||||||
|
latest = versions.first
|
||||||
|
expect(latest).to include(
|
||||||
|
'tag_id' => tag.id,
|
||||||
|
'version_no' => 2,
|
||||||
|
'event_type' => 'update',
|
||||||
|
'created_by_user' => {
|
||||||
|
'id' => member.id,
|
||||||
|
'name' => member.name
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(latest.fetch('name')).to eq(
|
||||||
|
'current' => 'new_tag_name',
|
||||||
|
'prev' => 'old_tag_name'
|
||||||
|
)
|
||||||
|
expect(latest.fetch('category')).to eq(
|
||||||
|
'current' => 'meme',
|
||||||
|
'prev' => 'general'
|
||||||
|
)
|
||||||
|
expect(latest.fetch('aliases')).to include(
|
||||||
|
{ 'name' => 'alias_shared', 'type' => 'context' },
|
||||||
|
{ 'name' => 'alias_new', 'type' => 'added' },
|
||||||
|
{ 'name' => 'alias_old', 'type' => 'removed' }
|
||||||
|
)
|
||||||
|
expect(latest.fetch('parent_tags')).to include(
|
||||||
|
a_hash_including(
|
||||||
|
'type' => 'context',
|
||||||
|
'tag' => a_hash_including(
|
||||||
|
'id' => parent_shared.id
|
||||||
|
)
|
||||||
|
),
|
||||||
|
a_hash_including(
|
||||||
|
'type' => 'added',
|
||||||
|
'tag' => a_hash_including(
|
||||||
|
'id' => parent_new.id
|
||||||
|
)
|
||||||
|
),
|
||||||
|
a_hash_including(
|
||||||
|
'type' => 'removed',
|
||||||
|
'tag' => a_hash_including(
|
||||||
|
'id' => parent_old.id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
expect(latest.fetch('created_at')).to eq(t_v2.iso8601)
|
||||||
|
|
||||||
|
first = versions.second
|
||||||
|
expect(first).to include(
|
||||||
|
'tag_id' => tag.id,
|
||||||
|
'version_no' => 1,
|
||||||
|
'event_type' => 'create',
|
||||||
|
'created_by_user' => {
|
||||||
|
'id' => member.id,
|
||||||
|
'name' => member.name
|
||||||
|
}
|
||||||
|
)
|
||||||
|
expect(first.fetch('name')).to eq(
|
||||||
|
'current' => 'old_tag_name',
|
||||||
|
'prev' => nil
|
||||||
|
)
|
||||||
|
expect(first.fetch('category')).to eq(
|
||||||
|
'current' => 'general',
|
||||||
|
'prev' => nil
|
||||||
|
)
|
||||||
|
expect(first.fetch('aliases')).to include(
|
||||||
|
{ 'name' => 'alias_shared', 'type' => 'added' },
|
||||||
|
{ 'name' => 'alias_old', 'type' => 'added' }
|
||||||
|
)
|
||||||
|
expect(first.fetch('parent_tags')).to include(
|
||||||
|
a_hash_including(
|
||||||
|
'type' => 'added',
|
||||||
|
'tag' => a_hash_including(
|
||||||
|
'id' => parent_shared.id
|
||||||
|
)
|
||||||
|
),
|
||||||
|
a_hash_including(
|
||||||
|
'type' => 'added',
|
||||||
|
'tag' => a_hash_including(
|
||||||
|
'id' => parent_old.id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
expect(first.fetch('created_at')).to eq(t_v1.iso8601)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns empty when the specified tag has no versions' do
|
||||||
|
fresh_tag = create(:tag, name: 'no_versions_tag', category: :general)
|
||||||
|
|
||||||
|
get '/tags/versions', params: { id: fresh_tag.id }
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(json.fetch('versions')).to eq([])
|
||||||
|
expect(json.fetch('count')).to eq(0)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'clamps page and limit to at least 1' do
|
||||||
|
get '/tags/versions', params: { id: tag.id, page: 0, limit: 0 }
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(json.fetch('count')).to eq(2)
|
||||||
|
|
||||||
|
versions = json.fetch('versions')
|
||||||
|
expect(versions.size).to eq(1)
|
||||||
|
expect(versions.first['version_no']).to eq(2)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'does not create tag versions by wiki updates when tag has no versions yet' do
|
||||||
|
wiki_tag_name = TagName.create!(name: 'tag_versions_from_wiki')
|
||||||
|
wiki_tag = Tag.create!(tag_name: wiki_tag_name, category: :general)
|
||||||
|
|
||||||
|
wiki_page =
|
||||||
|
Wiki::Commit.create_content!(
|
||||||
|
tag_name: wiki_tag_name,
|
||||||
|
body: 'before',
|
||||||
|
created_by_user: member,
|
||||||
|
message: 'init')
|
||||||
|
|
||||||
|
Wiki::Commit.content!(
|
||||||
|
page: wiki_page,
|
||||||
|
body: 'after',
|
||||||
|
created_user: member,
|
||||||
|
message: 'edit',
|
||||||
|
base_revision_id: wiki_page.current_revision.id)
|
||||||
|
|
||||||
|
get '/tags/versions', params: { id: wiki_tag.id }
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(json.fetch('versions')).to eq([])
|
||||||
|
expect(json.fetch('count')).to eq(0)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe 'Tag and wiki history integrity', type: :request do
|
||||||
|
let(:member_user) { create(:user, role: 'member') }
|
||||||
|
|
||||||
|
def stub_current_user user
|
||||||
|
allow_any_instance_of(ApplicationController).to receive(:current_user).and_return(user)
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_tag! name:, category: :general
|
||||||
|
tag_name = TagName.create!(name:)
|
||||||
|
Tag.create!(tag_name:, category:)
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_wiki_for_tag! tag:, body: 'wiki body', user: member_user
|
||||||
|
Wiki::Commit.create_content!(
|
||||||
|
tag_name: tag.tag_name,
|
||||||
|
body:,
|
||||||
|
created_by_user: user,
|
||||||
|
message: 'init')
|
||||||
|
end
|
||||||
|
|
||||||
|
before do
|
||||||
|
stub_current_user(member_user)
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'PATCH /tags/:id' do
|
||||||
|
it 'records wiki_version when tag name changes and tag has wiki' do
|
||||||
|
tag = create_tag!(name: 'patch_tag_wiki_before')
|
||||||
|
wiki_page = create_wiki_for_tag!(tag:, body: 'wiki body before')
|
||||||
|
|
||||||
|
expect {
|
||||||
|
patch "/tags/#{ tag.id }", params: {
|
||||||
|
name: 'patch_tag_wiki_after',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.to change(TagVersion, :count).by(2)
|
||||||
|
.and change(WikiVersion, :count).by(1)
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
|
||||||
|
tag.reload
|
||||||
|
wiki_page.reload
|
||||||
|
version = wiki_page.wiki_versions.order(:version_no).last
|
||||||
|
|
||||||
|
expect(tag.name).to eq('patch_tag_wiki_after')
|
||||||
|
expect(wiki_page.title).to eq('patch_tag_wiki_after')
|
||||||
|
|
||||||
|
expect(version).to have_attributes(
|
||||||
|
event_type: 'update',
|
||||||
|
title: 'patch_tag_wiki_after',
|
||||||
|
body: 'wiki body before',
|
||||||
|
created_by_user_id: member_user.id
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'does not record wiki_version when only category changes' do
|
||||||
|
tag = create_tag!(name: 'patch_tag_category_only')
|
||||||
|
wiki_page = create_wiki_for_tag!(tag:, body: 'wiki body before')
|
||||||
|
|
||||||
|
before_wiki_versions = wiki_page.wiki_versions.count
|
||||||
|
|
||||||
|
expect {
|
||||||
|
patch "/tags/#{ tag.id }", params: {
|
||||||
|
category: 'meme',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.to change(TagVersion, :count).by(2)
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
|
||||||
|
tag.reload
|
||||||
|
wiki_page.reload
|
||||||
|
|
||||||
|
expect(tag.name).to eq('patch_tag_category_only')
|
||||||
|
expect(tag.category).to eq('meme')
|
||||||
|
expect(wiki_page.wiki_versions.count).to eq(before_wiki_versions)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'PUT /tags/:id' do
|
||||||
|
it 'records wiki_version when tag name changes and tag has wiki' do
|
||||||
|
tag = create_tag!(name: 'put_tag_wiki_before')
|
||||||
|
wiki_page = create_wiki_for_tag!(tag:, body: 'wiki body before')
|
||||||
|
|
||||||
|
expect {
|
||||||
|
put "/tags/#{ tag.id }", params: {
|
||||||
|
name: 'put_tag_wiki_after',
|
||||||
|
category: 'general',
|
||||||
|
aliases: '',
|
||||||
|
parent_tags: '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.to change(TagVersion, :count).by(2)
|
||||||
|
.and change(WikiVersion, :count).by(1)
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
|
||||||
|
tag.reload
|
||||||
|
wiki_page.reload
|
||||||
|
version = wiki_page.wiki_versions.order(:version_no).last
|
||||||
|
|
||||||
|
expect(tag.name).to eq('put_tag_wiki_after')
|
||||||
|
expect(wiki_page.title).to eq('put_tag_wiki_after')
|
||||||
|
|
||||||
|
expect(version).to have_attributes(
|
||||||
|
event_type: 'update',
|
||||||
|
title: 'put_tag_wiki_after',
|
||||||
|
body: 'wiki body before',
|
||||||
|
created_by_user_id: member_user.id
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'does not record wiki_version when only category changes' do
|
||||||
|
tag = create_tag!(name: 'put_tag_category_only')
|
||||||
|
wiki_page = create_wiki_for_tag!(tag:, body: 'wiki body before')
|
||||||
|
|
||||||
|
before_wiki_versions = wiki_page.wiki_versions.count
|
||||||
|
|
||||||
|
expect {
|
||||||
|
put "/tags/#{ tag.id }", params: {
|
||||||
|
name: 'put_tag_category_only',
|
||||||
|
category: 'meme',
|
||||||
|
aliases: '',
|
||||||
|
parent_tags: '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.to change(TagVersion, :count).by(2)
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
|
||||||
|
tag.reload
|
||||||
|
wiki_page.reload
|
||||||
|
|
||||||
|
expect(tag.name).to eq('put_tag_category_only')
|
||||||
|
expect(tag.category).to eq('meme')
|
||||||
|
expect(wiki_page.wiki_versions.count).to eq(before_wiki_versions)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'does not record wiki_version when only aliases change' do
|
||||||
|
tag = create_tag!(name: 'put_tag_alias_only')
|
||||||
|
wiki_page = create_wiki_for_tag!(tag:, body: 'wiki body before')
|
||||||
|
|
||||||
|
before_wiki_versions = wiki_page.wiki_versions.count
|
||||||
|
|
||||||
|
expect {
|
||||||
|
put "/tags/#{ tag.id }", params: {
|
||||||
|
name: 'put_tag_alias_only',
|
||||||
|
category: 'general',
|
||||||
|
aliases: 'put_tag_alias_only_alias',
|
||||||
|
parent_tags: '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.to change(TagVersion, :count).by(2)
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(wiki_page.reload.wiki_versions.count).to eq(before_wiki_versions)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -8,23 +8,35 @@ 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 and empty array' do
|
it 'returns 200 with tag and empty deerjikists 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
|
||||||
|
|
||||||
@@ -34,17 +46,27 @@ 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 and deerjikists array' do
|
it 'returns 200 with tag 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(Array)
|
expect(json).to be_a(Hash)
|
||||||
expect(json.size).to eq(2)
|
|
||||||
|
|
||||||
expect(json.map { |h| [h['platform'], h['code']] }).to contain_exactly(
|
expect(json['tag']).to be_a(Hash)
|
||||||
[platform1, code1],
|
expect(json['tag']['id']).to eq(tag.id)
|
||||||
[platform2, code2],
|
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],
|
||||||
|
[platform2, code2],
|
||||||
|
)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -53,6 +75,7 @@ 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
|
||||||
@@ -60,7 +83,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' }
|
||||||
@@ -70,6 +93,7 @@ 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
|
||||||
@@ -79,23 +103,209 @@ 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 and deerjikists array' do
|
it 'returns 200 with tag 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(Array)
|
expect(json).to be_a(Hash)
|
||||||
expect(json.size).to eq(1)
|
|
||||||
expect(json[0]['platform']).to eq(platform1)
|
expect(json['tag']).to be_a(Hash)
|
||||||
expect(json[0]['code']).to eq(code1)
|
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 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.size).to eq(1)
|
||||||
|
expect(json[0]['platform']).to eq('youtube')
|
||||||
|
expect(json[0]['code']).to eq(channel_id)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
require 'cgi'
|
require 'cgi'
|
||||||
require 'rails_helper'
|
require 'rails_helper'
|
||||||
|
|
||||||
|
|
||||||
RSpec.describe 'Tags API', type: :request do
|
RSpec.describe 'Tags API', type: :request do
|
||||||
let!(:tn) { TagName.create!(name: 'spec_tag') }
|
let!(:tn) { TagName.create!(name: 'spec_tag') }
|
||||||
let!(:tag) { Tag.create!(tag_name: tn, category: :general) }
|
let!(:tag) { Tag.create!(tag_name: tn, category: :general) }
|
||||||
@@ -19,6 +18,17 @@ RSpec.describe 'Tags API', type: :request do
|
|||||||
response_tags.map { |t| t.fetch('name') }
|
response_tags.map { |t| t.fetch('name') }
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def dummy_material_upload(filename: 'dummy.png', type: 'image/png', body: 'dummy')
|
||||||
|
Rack::Test::UploadedFile.new(StringIO.new(body), type, original_filename: filename)
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_material(tag, user:, filename: 'dummy.png', type: 'image/png', url: nil)
|
||||||
|
Material.new(tag:, url:, created_by_user: user, updated_by_user: user).tap do |material|
|
||||||
|
material.file.attach(dummy_material_upload(filename:, type:)) if filename
|
||||||
|
material.save!
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
describe 'GET /tags' do
|
describe 'GET /tags' do
|
||||||
it 'returns tags with count and metadata' do
|
it 'returns tags with count and metadata' do
|
||||||
get '/tags'
|
get '/tags'
|
||||||
@@ -186,6 +196,30 @@ RSpec.describe 'Tags API', type: :request do
|
|||||||
expect(response_tags.size).to eq(1)
|
expect(response_tags.size).to eq(1)
|
||||||
expect(response_names).to eq(['norm_a'])
|
expect(response_names).to eq(['norm_a'])
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'returns aliases and parent tags' do
|
||||||
|
parent_tag = Tag.create!(
|
||||||
|
tag_name: TagName.create!(name: 'index_parent_tag'),
|
||||||
|
category: :meme
|
||||||
|
)
|
||||||
|
TagImplication.create!(tag:, parent_tag:)
|
||||||
|
|
||||||
|
get '/tags', params: { name: 'spec_tag' }
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
|
||||||
|
row = response_tags.find { |t| t['name'] == 'spec_tag' }
|
||||||
|
|
||||||
|
expect(row['aliases']).to include('unko')
|
||||||
|
expect(row['parents'].map { |t| t['name'] }).to include('index_parent_tag')
|
||||||
|
|
||||||
|
parent = row['parents'].find { |t| t['name'] == 'index_parent_tag' }
|
||||||
|
expect(parent).to include(
|
||||||
|
'id' => parent_tag.id,
|
||||||
|
'name' => 'index_parent_tag',
|
||||||
|
'category' => 'meme'
|
||||||
|
)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe 'GET /tags/:id' do
|
describe 'GET /tags/:id' do
|
||||||
@@ -209,6 +243,28 @@ RSpec.describe 'Tags API', type: :request do
|
|||||||
expect(json).to have_key('created_at')
|
expect(json).to have_key('created_at')
|
||||||
expect(json).to have_key('updated_at')
|
expect(json).to have_key('updated_at')
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'returns aliases and parent tags' do
|
||||||
|
parent_tag = Tag.create!(
|
||||||
|
tag_name: TagName.create!(name: 'show_parent_tag'),
|
||||||
|
category: :character
|
||||||
|
)
|
||||||
|
TagImplication.create!(tag:, parent_tag:)
|
||||||
|
|
||||||
|
request
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
|
||||||
|
expect(json['aliases']).to include('unko')
|
||||||
|
expect(json['parents'].map { |t| t['name'] }).to include('show_parent_tag')
|
||||||
|
|
||||||
|
parent = json['parents'].find { |t| t['name'] == 'show_parent_tag' }
|
||||||
|
expect(parent).to include(
|
||||||
|
'id' => parent_tag.id,
|
||||||
|
'name' => 'show_parent_tag',
|
||||||
|
'category' => 'character'
|
||||||
|
)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'when tag does not exist' do
|
context 'when tag does not exist' do
|
||||||
@@ -348,14 +404,653 @@ RSpec.describe 'Tags API', type: :request do
|
|||||||
expect(tag.category).to eq('meta')
|
expect(tag.category).to eq('meta')
|
||||||
end
|
end
|
||||||
|
|
||||||
it '存在しない id だと RecordNotFound になる(通常は 404)' do
|
it '存在しない id なら 404 を返す' do
|
||||||
patch '/tags/999999999', params: { name: 'x' }
|
patch '/tags/999999999', params: { name: 'x' }
|
||||||
expect(response.status).to be_in([404, 500])
|
expect(response).to have_http_status(:not_found)
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'バリデーションで update! が失敗したら(通常は 422 か 500)' do
|
it 'nico category への変更は 422 を返す' do
|
||||||
patch "/tags/#{ tag.id }", params: { name: 'new', category: 'nico' }
|
patch "/tags/#{tag.id}", params: { name: 'new', category: 'nico' }
|
||||||
expect(response.status).to be_in([422, 500])
|
|
||||||
|
expect(response).to have_http_status(:unprocessable_entity)
|
||||||
|
expect(tag.reload.name).to eq('spec_tag')
|
||||||
|
expect(tag.category).to eq('general')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'creates initial and update tag versions when name and category change' do
|
||||||
|
expect {
|
||||||
|
patch "/tags/#{tag.id}", params: { name: 'new_tag_name', category: 'meme' }
|
||||||
|
}.to change(TagVersion, :count).by(2)
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
|
||||||
|
versions = tag.reload.tag_versions.order(:version_no)
|
||||||
|
|
||||||
|
expect(versions.map(&:event_type)).to eq(['create', 'update'])
|
||||||
|
|
||||||
|
expect(versions.first.name).to eq('spec_tag')
|
||||||
|
expect(versions.first.category).to eq('general')
|
||||||
|
expect(versions.first.aliases.split).to include('unko')
|
||||||
|
|
||||||
|
expect(versions.second.name).to eq('new_tag_name')
|
||||||
|
expect(versions.second.category).to eq('meme')
|
||||||
|
expect(versions.second.created_by_user_id).to eq(member_user.id)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns 422 when changing normal tag category to nico' do
|
||||||
|
expect {
|
||||||
|
patch "/tags/#{tag.id}", params: { category: 'nico' }
|
||||||
|
}.not_to change(TagVersion, :count)
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:unprocessable_entity)
|
||||||
|
expect(tag.reload.category).to eq('general')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns 422 when updating nico tag name' do
|
||||||
|
nico_tag_name = TagName.create!(name: 'nico:tags_spec_source')
|
||||||
|
nico_tag = Tag.create!(tag_name: nico_tag_name, category: :nico)
|
||||||
|
|
||||||
|
expect {
|
||||||
|
patch "/tags/#{ nico_tag.id }", params: { name: 'nico:tags_spec_renamed' }
|
||||||
|
}.not_to change(NicoTagVersion, :count)
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:unprocessable_entity)
|
||||||
|
|
||||||
|
expect(nico_tag.reload.name).to eq('nico:tags_spec_source')
|
||||||
|
expect(nico_tag.category).to eq('nico')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns 422 when changing nico tag category to normal category' do
|
||||||
|
nico_tag_name = TagName.create!(name: 'nico:category_change_ng')
|
||||||
|
nico_tag = Tag.create!(tag_name: nico_tag_name, category: :nico)
|
||||||
|
|
||||||
|
expect {
|
||||||
|
patch "/tags/#{nico_tag.id}", params: { category: 'general' }
|
||||||
|
}.not_to change(NicoTagVersion, :count)
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:unprocessable_entity)
|
||||||
|
expect(nico_tag.reload.category).to eq('nico')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'PATCH で tag の name を変更すると対応する wiki version を作成する' do
|
||||||
|
wiki_page =
|
||||||
|
Wiki::Commit.create_content!(
|
||||||
|
tag_name: tag.tag_name,
|
||||||
|
body: 'wiki body before',
|
||||||
|
created_by_user: member_user,
|
||||||
|
message: 'init')
|
||||||
|
|
||||||
|
expect {
|
||||||
|
patch "/tags/#{ tag.id }", params: {
|
||||||
|
name: 'patch_wiki_renamed_tag',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.to change(TagVersion, :count).by(2)
|
||||||
|
.and change(WikiVersion, :count).by(1)
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
|
||||||
|
version = wiki_page.reload.wiki_versions.order(:version_no).last
|
||||||
|
|
||||||
|
expect(version).to have_attributes(
|
||||||
|
event_type: 'update',
|
||||||
|
title: 'patch_wiki_renamed_tag',
|
||||||
|
body: 'wiki body before',
|
||||||
|
created_by_user_id: member_user.id
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'tag の category だけを変更しても wiki version は作成しない' do
|
||||||
|
wiki_page =
|
||||||
|
Wiki::Commit.create_content!(
|
||||||
|
tag_name: tag.tag_name,
|
||||||
|
body: 'wiki body before',
|
||||||
|
created_by_user: member_user,
|
||||||
|
message: 'init')
|
||||||
|
|
||||||
|
before_wiki_version_count = wiki_page.reload.wiki_versions.count
|
||||||
|
|
||||||
|
expect {
|
||||||
|
patch "/tags/#{ tag.id }", params: {
|
||||||
|
category: 'meme',
|
||||||
|
}
|
||||||
|
}.to change(TagVersion, :count).by(2)
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(wiki_page.reload.wiki_versions.count).to eq(before_wiki_version_count)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'GET /tags/with-depth' do
|
||||||
|
let!(:root_meme) do
|
||||||
|
Tag.create!(tag_name: TagName.create!(name: 'depth_a_root_meme'), category: :meme)
|
||||||
|
end
|
||||||
|
|
||||||
|
let!(:root_material) do
|
||||||
|
Tag.create!(tag_name: TagName.create!(name: 'depth_b_root_material'), category: :material)
|
||||||
|
end
|
||||||
|
|
||||||
|
let!(:hidden_general_root) do
|
||||||
|
Tag.create!(tag_name: TagName.create!(name: 'depth_hidden_general_root'), category: :general)
|
||||||
|
end
|
||||||
|
|
||||||
|
let!(:child_character) do
|
||||||
|
Tag.create!(tag_name: TagName.create!(name: 'depth_child_character'), category: :character)
|
||||||
|
end
|
||||||
|
|
||||||
|
let!(:grandchild_material) do
|
||||||
|
Tag.create!(tag_name: TagName.create!(name: 'depth_grandchild_material'), category: :material)
|
||||||
|
end
|
||||||
|
|
||||||
|
let!(:child_general) do
|
||||||
|
Tag.create!(tag_name: TagName.create!(name: 'depth_child_general'), category: :general)
|
||||||
|
end
|
||||||
|
|
||||||
|
before do
|
||||||
|
TagImplication.create!(parent_tag: root_meme, tag: child_character)
|
||||||
|
TagImplication.create!(parent_tag: child_character, tag: grandchild_material)
|
||||||
|
TagImplication.create!(parent_tag: root_material, tag: child_general)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns only visible root tags and visible has_children flags' do
|
||||||
|
get '/tags/with-depth'
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(json.map { |t| t['name'] }).to eq([
|
||||||
|
'depth_a_root_meme',
|
||||||
|
'depth_b_root_material'
|
||||||
|
])
|
||||||
|
|
||||||
|
meme_row = json.find { |t| t['name'] == 'depth_a_root_meme' }
|
||||||
|
material_row = json.find { |t| t['name'] == 'depth_b_root_material' }
|
||||||
|
|
||||||
|
expect(meme_row['has_children']).to eq(true)
|
||||||
|
expect(meme_row['children']).to eq([])
|
||||||
|
|
||||||
|
expect(material_row['has_children']).to eq(false)
|
||||||
|
expect(material_row['children']).to eq([])
|
||||||
|
|
||||||
|
expect(json.map { |t| t['name'] }).not_to include('depth_hidden_general_root')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns children of the specified parent' do
|
||||||
|
get '/tags/with-depth', params: { parent: root_meme.id }
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(json.map { |t| t['name'] }).to eq(['depth_child_character'])
|
||||||
|
|
||||||
|
row = json.first
|
||||||
|
expect(row['category']).to eq('character')
|
||||||
|
expect(row['has_children']).to eq(true)
|
||||||
|
expect(row['children']).to eq([])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'GET /tags/name/:name/materials' do
|
||||||
|
let!(:material_user) { create_member_user! }
|
||||||
|
|
||||||
|
let!(:root_tag) do
|
||||||
|
Tag.create!(tag_name: TagName.create!(name: 'materials_root'), category: :material)
|
||||||
|
end
|
||||||
|
|
||||||
|
let!(:child_a_tag) do
|
||||||
|
Tag.create!(tag_name: TagName.create!(name: 'materials_child_a'), category: :material)
|
||||||
|
end
|
||||||
|
|
||||||
|
let!(:child_b_tag) do
|
||||||
|
Tag.create!(tag_name: TagName.create!(name: 'materials_child_b'), category: :character)
|
||||||
|
end
|
||||||
|
|
||||||
|
let!(:grandchild_tag) do
|
||||||
|
Tag.create!(tag_name: TagName.create!(name: 'materials_grandchild'), category: :material)
|
||||||
|
end
|
||||||
|
|
||||||
|
let!(:root_material) do
|
||||||
|
create_material(root_tag, user: material_user, filename: 'root.png')
|
||||||
|
end
|
||||||
|
|
||||||
|
let!(:child_a_material) do
|
||||||
|
create_material(child_a_tag, user: material_user, filename: 'child_a.png')
|
||||||
|
end
|
||||||
|
|
||||||
|
let!(:grandchild_material) do
|
||||||
|
create_material(grandchild_tag, user: material_user, filename: 'grandchild.png')
|
||||||
|
end
|
||||||
|
|
||||||
|
before do
|
||||||
|
TagImplication.create!(parent_tag: root_tag, tag: child_b_tag)
|
||||||
|
TagImplication.create!(parent_tag: root_tag, tag: child_a_tag)
|
||||||
|
TagImplication.create!(parent_tag: child_a_tag, tag: grandchild_tag)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns a tag tree with nested materials sorted by child name' do
|
||||||
|
get "/tags/name/#{ CGI.escape(root_tag.name) }/materials"
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
|
||||||
|
expect(json).to include(
|
||||||
|
'id' => root_tag.id,
|
||||||
|
'name' => 'materials_root',
|
||||||
|
'category' => 'material'
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(json['material']).to be_present
|
||||||
|
expect(json.dig('material', 'id')).to eq(root_material.id)
|
||||||
|
expect(json.dig('material', 'file')).to be_present
|
||||||
|
expect(json.dig('material', 'content_type')).to eq('image/png')
|
||||||
|
|
||||||
|
expect(json['children'].map { |t| t['name'] }).to eq([
|
||||||
|
'materials_child_a',
|
||||||
|
'materials_child_b'
|
||||||
|
])
|
||||||
|
|
||||||
|
child_a = json['children'].find { |t| t['name'] == 'materials_child_a' }
|
||||||
|
child_b = json['children'].find { |t| t['name'] == 'materials_child_b' }
|
||||||
|
|
||||||
|
expect(child_a.dig('material', 'id')).to eq(child_a_material.id)
|
||||||
|
expect(child_a['children'].map { |t| t['name'] }).to eq(['materials_grandchild'])
|
||||||
|
expect(child_a.dig('children', 0, 'material', 'id')).to eq(grandchild_material.id)
|
||||||
|
|
||||||
|
expect(child_b['material']).to be_nil
|
||||||
|
expect(child_b['children']).to eq([])
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns 404 when the tag does not exist' do
|
||||||
|
get '/tags/name/no_such_tag_12345/materials'
|
||||||
|
expect(response).to have_http_status(:not_found)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'PUT /tags/:id' do
|
||||||
|
context '未ログイン' do
|
||||||
|
before { stub_current_user(nil) }
|
||||||
|
|
||||||
|
it '401 を返す' do
|
||||||
|
put "/tags/#{ tag.id }", params: {
|
||||||
|
name: 'new',
|
||||||
|
category: 'general',
|
||||||
|
aliases: '',
|
||||||
|
parent_tags: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:unauthorized)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'ログインしてゐるが member でない' do
|
||||||
|
before { stub_current_user(non_member_user) }
|
||||||
|
|
||||||
|
it '403 を返す' do
|
||||||
|
put "/tags/#{ tag.id }", params: {
|
||||||
|
name: 'new',
|
||||||
|
category: 'general',
|
||||||
|
aliases: '',
|
||||||
|
parent_tags: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:forbidden)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'member' do
|
||||||
|
before { stub_current_user(member_user) }
|
||||||
|
|
||||||
|
it '存在しない id なら 404 を返す' do
|
||||||
|
put '/tags/999999999', params: {
|
||||||
|
name: 'new',
|
||||||
|
category: 'general',
|
||||||
|
aliases: '',
|
||||||
|
parent_tags: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:not_found)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'name が空なら 422 を返す' do
|
||||||
|
put "/tags/#{ tag.id }", params: {
|
||||||
|
name: '',
|
||||||
|
category: 'general',
|
||||||
|
aliases: '',
|
||||||
|
parent_tags: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:unprocessable_entity)
|
||||||
|
expect(tag.reload.name).to eq('spec_tag')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'category が空なら 422 を返す' do
|
||||||
|
put "/tags/#{ tag.id }", params: {
|
||||||
|
name: 'new',
|
||||||
|
category: '',
|
||||||
|
aliases: '',
|
||||||
|
parent_tags: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:unprocessable_entity)
|
||||||
|
expect(tag.reload.name).to eq('spec_tag')
|
||||||
|
expect(tag.category).to eq('general')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'name, category, aliases, parent tags をまとめて更新できる' do
|
||||||
|
old_parent = Tag.create!(
|
||||||
|
tag_name: TagName.create!(name: 'put_old_parent'),
|
||||||
|
category: :general
|
||||||
|
)
|
||||||
|
kept_parent = Tag.create!(
|
||||||
|
tag_name: TagName.create!(name: 'put_kept_parent'),
|
||||||
|
category: :general
|
||||||
|
)
|
||||||
|
TagImplication.create!(tag:, parent_tag: old_parent)
|
||||||
|
TagImplication.create!(tag:, parent_tag: kept_parent)
|
||||||
|
|
||||||
|
put "/tags/#{ tag.id }", params: {
|
||||||
|
name: 'put_renamed_tag',
|
||||||
|
category: 'meme',
|
||||||
|
aliases: 'put_alias_a put_alias_b put_alias_a',
|
||||||
|
parent_tags: 'put_kept_parent put_new_parent',
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
|
||||||
|
tag.reload
|
||||||
|
|
||||||
|
expect(tag.name).to eq('put_renamed_tag')
|
||||||
|
expect(tag.category).to eq('meme')
|
||||||
|
|
||||||
|
expect(TagName.find_by(name: 'put_alias_a').canonical).to eq(tag.tag_name)
|
||||||
|
expect(TagName.find_by(name: 'put_alias_b').canonical).to eq(tag.tag_name)
|
||||||
|
|
||||||
|
old_name_alias = TagName.find_by(name: 'spec_tag')
|
||||||
|
expect(old_name_alias).to be_present
|
||||||
|
expect(old_name_alias.canonical).to eq(tag.tag_name)
|
||||||
|
|
||||||
|
expect(alias_tn.reload.canonical).to be_nil
|
||||||
|
|
||||||
|
expect(tag.parents.map(&:name)).to contain_exactly(
|
||||||
|
'put_kept_parent',
|
||||||
|
'put_new_parent'
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(TagImplication.where(tag:, parent_tag: old_parent)).not_to exist
|
||||||
|
|
||||||
|
expect(json['name']).to eq('put_renamed_tag')
|
||||||
|
expect(json['category']).to eq('meme')
|
||||||
|
expect(json['aliases']).to contain_exactly(
|
||||||
|
'put_alias_a',
|
||||||
|
'put_alias_b',
|
||||||
|
'spec_tag'
|
||||||
|
)
|
||||||
|
expect(json['parents'].map { |t| t['name'] }).to contain_exactly(
|
||||||
|
'put_kept_parent',
|
||||||
|
'put_new_parent'
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'aliases に現在名を指定しても alias には残さない' do
|
||||||
|
put "/tags/#{ tag.id }", params: {
|
||||||
|
name: 'spec_tag',
|
||||||
|
category: 'general',
|
||||||
|
aliases: 'spec_tag put_alias_self_test',
|
||||||
|
parent_tags: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
|
||||||
|
tag.reload
|
||||||
|
|
||||||
|
expect(TagName.find_by(name: 'put_alias_self_test').canonical).to eq(tag.tag_name)
|
||||||
|
expect(json['aliases']).to include('put_alias_self_test')
|
||||||
|
expect(json['aliases']).not_to include('spec_tag')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'parent_tags に自分自身を指定しても自己参照は作らない' do
|
||||||
|
put "/tags/#{ tag.id }", params: {
|
||||||
|
name: 'spec_tag',
|
||||||
|
category: 'general',
|
||||||
|
aliases: 'unko',
|
||||||
|
parent_tags: 'spec_tag',
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
|
||||||
|
expect(TagImplication.where(tag:, parent_tag: tag)).not_to exist
|
||||||
|
expect(tag.reload.parents).to eq([])
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'initial and update tag versions を作成する' do
|
||||||
|
expect {
|
||||||
|
put "/tags/#{ tag.id }", params: {
|
||||||
|
name: 'put_versioned_tag',
|
||||||
|
category: 'meta',
|
||||||
|
aliases: '',
|
||||||
|
parent_tags: '',
|
||||||
|
}
|
||||||
|
}.to change(TagVersion, :count).by(2)
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
|
||||||
|
versions = tag.reload.tag_versions.order(:version_no)
|
||||||
|
|
||||||
|
expect(versions.map(&:event_type)).to eq(['create', 'update'])
|
||||||
|
|
||||||
|
expect(versions.first.name).to eq('spec_tag')
|
||||||
|
expect(versions.first.category).to eq('general')
|
||||||
|
expect(versions.first.aliases.split).to include('unko')
|
||||||
|
|
||||||
|
expect(versions.second.name).to eq('put_versioned_tag')
|
||||||
|
expect(versions.second.category).to eq('meta')
|
||||||
|
expect(versions.second.aliases.split).to include('spec_tag')
|
||||||
|
expect(versions.second.created_by_user_id).to eq(member_user.id)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'parent tag の snapshot も作成する' do
|
||||||
|
old_parent = Tag.create!(
|
||||||
|
tag_name: TagName.create!(name: 'put_snapshot_old_parent'),
|
||||||
|
category: :general
|
||||||
|
)
|
||||||
|
new_parent = Tag.create!(
|
||||||
|
tag_name: TagName.create!(name: 'put_snapshot_new_parent'),
|
||||||
|
category: :general
|
||||||
|
)
|
||||||
|
TagImplication.create!(tag:, parent_tag: old_parent)
|
||||||
|
|
||||||
|
put "/tags/#{ tag.id }", params: {
|
||||||
|
name: 'spec_tag',
|
||||||
|
category: 'general',
|
||||||
|
aliases: 'unko',
|
||||||
|
parent_tags: new_parent.name,
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
|
||||||
|
expect(old_parent.reload.tag_versions.map(&:event_type)).to include('create')
|
||||||
|
expect(new_parent.reload.tag_versions.map(&:event_type)).to include('create')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'normal tag を nico category には変更できない' do
|
||||||
|
expect {
|
||||||
|
put "/tags/#{ tag.id }", params: {
|
||||||
|
name: 'spec_tag',
|
||||||
|
category: 'nico',
|
||||||
|
aliases: '',
|
||||||
|
parent_tags: '',
|
||||||
|
}
|
||||||
|
}.not_to change(TagVersion, :count)
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:unprocessable_entity)
|
||||||
|
|
||||||
|
expect(tag.reload.name).to eq('spec_tag')
|
||||||
|
expect(tag.category).to eq('general')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'nico tag は更新できない' do
|
||||||
|
nico_tag = Tag.create!(
|
||||||
|
tag_name: TagName.create!(name: 'nico:put_update_all_ng'),
|
||||||
|
category: :nico
|
||||||
|
)
|
||||||
|
|
||||||
|
expect {
|
||||||
|
put "/tags/#{ nico_tag.id }", params: {
|
||||||
|
name: 'nico:put_update_all_renamed',
|
||||||
|
category: 'nico',
|
||||||
|
aliases: '',
|
||||||
|
parent_tags: '',
|
||||||
|
}
|
||||||
|
}.not_to change(NicoTagVersion, :count)
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:unprocessable_entity)
|
||||||
|
|
||||||
|
expect(nico_tag.reload.name).to eq('nico:put_update_all_ng')
|
||||||
|
expect(nico_tag.category).to eq('nico')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'system tag の name は変更できない' do
|
||||||
|
system_tag = Tag.tagme
|
||||||
|
old_name = system_tag.name
|
||||||
|
old_category = system_tag.category
|
||||||
|
|
||||||
|
expect {
|
||||||
|
put "/tags/#{ system_tag.id }", params: {
|
||||||
|
name: 'put_system_tag_renamed',
|
||||||
|
category: old_category,
|
||||||
|
aliases: '',
|
||||||
|
parent_tags: '',
|
||||||
|
}
|
||||||
|
}.not_to change(TagVersion, :count)
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:unprocessable_entity)
|
||||||
|
|
||||||
|
expect(system_tag.reload.name).to eq(old_name)
|
||||||
|
expect(system_tag.category).to eq(old_category)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'wiki を持つ tag を更新すると wiki version も作成する' do
|
||||||
|
wiki_page =
|
||||||
|
Wiki::Commit.create_content!(
|
||||||
|
tag_name: tag.tag_name,
|
||||||
|
body: 'wiki body before',
|
||||||
|
created_by_user: member_user,
|
||||||
|
message: 'init')
|
||||||
|
|
||||||
|
Wiki::Commit.content!(
|
||||||
|
page: wiki_page,
|
||||||
|
body: 'wiki body before',
|
||||||
|
created_user: member_user,
|
||||||
|
message: 'init'
|
||||||
|
)
|
||||||
|
|
||||||
|
expect {
|
||||||
|
put "/tags/#{ tag.id }", params: {
|
||||||
|
name: 'put_wiki_version_tag',
|
||||||
|
category: 'meme',
|
||||||
|
aliases: 'unko',
|
||||||
|
parent_tags: '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.to change(TagVersion, :count).by(2)
|
||||||
|
.and change(WikiVersion, :count).by(1)
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
|
||||||
|
version = wiki_page.reload.wiki_versions.order(:version_no).last
|
||||||
|
|
||||||
|
expect(version).to have_attributes(
|
||||||
|
event_type: 'update',
|
||||||
|
title: 'put_wiki_version_tag',
|
||||||
|
body: 'wiki body before',
|
||||||
|
created_by_user_id: member_user.id
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
it '別名を他 tag から奪った場合、奪はれた側の tag version も作成する' do
|
||||||
|
old_owner = Tag.create!(
|
||||||
|
tag_name: TagName.create!(name: 'put_alias_old_owner'),
|
||||||
|
category: :general
|
||||||
|
)
|
||||||
|
stolen_alias = TagName.create!(
|
||||||
|
name: 'put_stolen_alias',
|
||||||
|
canonical: old_owner.tag_name
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(old_owner.tag_name.aliases.map(&:name)).to include('put_stolen_alias')
|
||||||
|
|
||||||
|
expect {
|
||||||
|
put "/tags/#{ tag.id }", params: {
|
||||||
|
name: 'spec_tag',
|
||||||
|
category: 'general',
|
||||||
|
aliases: 'unko put_stolen_alias',
|
||||||
|
parent_tags: '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.to change { tag.reload.tag_versions.count }.by(2)
|
||||||
|
.and change { old_owner.reload.tag_versions.count }.by(2)
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
|
||||||
|
expect(stolen_alias.reload.canonical).to eq(tag.tag_name)
|
||||||
|
expect(old_owner.reload.tag_name.aliases.map(&:name)).not_to include('put_stolen_alias')
|
||||||
|
|
||||||
|
old_owner_versions = old_owner.tag_versions.order(:version_no)
|
||||||
|
|
||||||
|
expect(old_owner_versions.first.event_type).to eq('create')
|
||||||
|
expect(old_owner_versions.first.aliases.split).to include('put_stolen_alias')
|
||||||
|
|
||||||
|
expect(old_owner_versions.second.event_type).to eq('update')
|
||||||
|
expect(old_owner_versions.second.aliases.split).not_to include('put_stolen_alias')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'parent_tags に指定すると循環する tag は 422 にする' do
|
||||||
|
pending '#332 で対応予定'
|
||||||
|
|
||||||
|
child = Tag.create!(
|
||||||
|
tag_name: TagName.create!(name: 'put_cycle_child'),
|
||||||
|
category: :general
|
||||||
|
)
|
||||||
|
|
||||||
|
TagImplication.create!(tag: child, parent_tag: tag)
|
||||||
|
|
||||||
|
put "/tags/#{ tag.id }", params: {
|
||||||
|
name: 'spec_tag',
|
||||||
|
category: 'general',
|
||||||
|
aliases: 'unko',
|
||||||
|
parent_tags: child.name,
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:unprocessable_entity)
|
||||||
|
|
||||||
|
expect(TagImplication.where(tag:, parent_tag: child)).not_to exist
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'tag の name を変更すると対応する wiki version を作成する' do
|
||||||
|
wiki_page =
|
||||||
|
Wiki::Commit.create_content!(
|
||||||
|
tag_name: tag.tag_name,
|
||||||
|
body: 'wiki body before',
|
||||||
|
created_by_user: member_user,
|
||||||
|
message: 'init')
|
||||||
|
|
||||||
|
expect {
|
||||||
|
put "/tags/#{ tag.id }", params: {
|
||||||
|
name: 'put_wiki_renamed_tag',
|
||||||
|
category: 'general',
|
||||||
|
aliases: 'unko',
|
||||||
|
parent_tags: '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.to change(TagVersion, :count).by(2)
|
||||||
|
.and change(WikiVersion, :count).by(1)
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
|
||||||
|
version = wiki_page.reload.wiki_versions.order(:version_no).last
|
||||||
|
|
||||||
|
expect(version).to have_attributes(
|
||||||
|
event_type: 'update',
|
||||||
|
title: 'put_wiki_renamed_tag',
|
||||||
|
body: 'wiki body before',
|
||||||
|
created_by_user_id: member_user.id
|
||||||
|
)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,110 +1,266 @@
|
|||||||
require "rails_helper"
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe 'Users', type: :request do
|
||||||
|
let(:remote_ip) { '203.0.113.10' }
|
||||||
|
|
||||||
RSpec.describe "Users", type: :request do
|
before do
|
||||||
describe "POST /users" do
|
allow_any_instance_of(ActionDispatch::Request)
|
||||||
it "creates guest user and returns code" do
|
.to receive(:remote_ip)
|
||||||
post "/users"
|
.and_return(remote_ip)
|
||||||
expect(response).to have_http_status(:ok)
|
end
|
||||||
expect(json["code"]).to be_present
|
|
||||||
expect(json["user"]["role"]).to eq("guest")
|
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)
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:created)
|
||||||
|
expect(json['code']).to be_present
|
||||||
|
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
|
||||||
sign_out
|
post '/users/code/renew'
|
||||||
post "/users/code/renew"
|
|
||||||
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
|
||||||
|
user = create(:user, :banned)
|
||||||
|
|
||||||
|
post '/users/code/renew', headers: auth_headers(user)
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:forbidden)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns 403 when current IP address is banned' do
|
||||||
|
user = create(:user)
|
||||||
|
|
||||||
|
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
|
end
|
||||||
|
|
||||||
describe "PUT /users/:id" do
|
describe 'PUT /users/:id' do
|
||||||
let(:user) { create(:user, name: "old-name", role: "guest") }
|
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)
|
||||||
|
|
||||||
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 400 when name is blank" do
|
it 'returns 400 when name is blank' do
|
||||||
sign_in_as(user)
|
put "/users/#{user.id}",
|
||||||
put "/users/#{user.id}", params: { name: " " }
|
params: { name: ' ' },
|
||||||
|
headers: auth_headers(user)
|
||||||
|
|
||||||
expect(response).to have_http_status(:bad_request)
|
expect(response).to have_http_status(:bad_request)
|
||||||
end
|
end
|
||||||
|
|
||||||
it "updates name and returns 201 with user slice" do
|
it 'updates name and returns user slice' do
|
||||||
sign_in_as(user)
|
put "/users/#{user.id}",
|
||||||
put "/users/#{user.id}", params: { name: "new-name" }
|
params: { name: 'new-name' },
|
||||||
|
headers: auth_headers(user)
|
||||||
|
|
||||||
expect(response).to have_http_status(:created)
|
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 "creates IpAddress and UserIp, and returns valid:true with user slice" do
|
it 'returns 403 when current IP address is banned' do
|
||||||
user = create(:user, inheritance_code: SecureRandom.uuid, role: "guest")
|
user = create(:user, inheritance_code: SecureRandom.uuid, role: 'guest')
|
||||||
|
|
||||||
# request.remote_ip を固定
|
IpAddress.create!(
|
||||||
allow_any_instance_of(ActionDispatch::Request).to receive(:remote_ip).and_return("203.0.113.10")
|
ip_address: IPAddr.new(remote_ip).hton,
|
||||||
|
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)
|
}.to change(UserIp, :count).by(1)
|
||||||
|
.and change(IpAddress, :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']['id']).to eq(user.id)
|
||||||
expect(json["user"]["inheritance_code"]).to eq(user.inheritance_code)
|
expect(json['user']['inheritance_code']).to eq(user.inheritance_code)
|
||||||
expect(json["user"]["role"]).to eq("guest")
|
expect(json['user']['role']).to eq('guest')
|
||||||
|
|
||||||
# ついでに IpAddress もできてることを確認(ipの保存形式がバイナリでも count で見れる)
|
ip_address = IpAddress.find_by(ip_address: IPAddr.new(remote_ip).hton)
|
||||||
expect(IpAddress.count).to be >= 1
|
expect(ip_address).to be_present
|
||||||
|
expect(UserIp.exists?(user:, ip_address:)).to eq(true)
|
||||||
end
|
end
|
||||||
|
|
||||||
it "is idempotent for same user+ip (does not create duplicate UserIp)" do
|
it 'is idempotent for same user and same IP address' do
|
||||||
user = create(:user, inheritance_code: SecureRandom.uuid, role: "guest")
|
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 }
|
post '/users/verify', params: { code: user.inheritance_code }
|
||||||
expect(response).to have_http_status(:ok)
|
expect(response).to have_http_status(:ok)
|
||||||
|
|
||||||
expect {
|
expect {
|
||||||
post "/users/verify", params: { code: user.inheritance_code }
|
post '/users/verify', params: { code: user.inheritance_code }
|
||||||
}.not_to change(UserIp, :count)
|
}.not_to change(UserIp, :count)
|
||||||
|
|
||||||
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)
|
||||||
|
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)
|
||||||
|
|
||||||
|
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
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe 'Wiki body search', type: :request do
|
||||||
|
let!(:user) { create_member_user! }
|
||||||
|
|
||||||
|
it 'searches wiki pages by body text' do
|
||||||
|
pending '#336 で対応予定'
|
||||||
|
|
||||||
|
Wiki::Commit.create_content!(
|
||||||
|
tag_name: TagName.create!(name: 'wiki_body_search_hit'),
|
||||||
|
body: 'unique body keyword for wiki search',
|
||||||
|
created_by_user: user,
|
||||||
|
message: 'init')
|
||||||
|
|
||||||
|
Wiki::Commit.create_content!(
|
||||||
|
tag_name: TagName.create!(name: 'wiki_body_search_miss'),
|
||||||
|
body: 'ordinary body',
|
||||||
|
created_by_user: user,
|
||||||
|
message: 'init')
|
||||||
|
|
||||||
|
get '/wiki/search', params: { body: 'unique body keyword' }
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(json.map { |page| page['title'] }).to include('wiki_body_search_hit')
|
||||||
|
expect(json.map { |page| page['title'] }).not_to include('wiki_body_search_miss')
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe 'Wiki conflict handling', type: :request do
|
||||||
|
let!(:user) { create_member_user! }
|
||||||
|
|
||||||
|
def auth_headers user
|
||||||
|
{ 'X-Transfer-Code' => user.inheritance_code }
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns 409 when base_revision_id is stale' do
|
||||||
|
page =
|
||||||
|
Wiki::Commit.create_content!(
|
||||||
|
tag_name: TagName.create!(name: 'wiki_conflict_request'),
|
||||||
|
body: 'first',
|
||||||
|
created_by_user: user,
|
||||||
|
message: 'init')
|
||||||
|
|
||||||
|
stale_id = page.current_revision.id
|
||||||
|
|
||||||
|
Wiki::Commit.content!(
|
||||||
|
page:,
|
||||||
|
body: 'second',
|
||||||
|
created_user: user,
|
||||||
|
message: 'other edit',
|
||||||
|
base_revision_id: stale_id)
|
||||||
|
|
||||||
|
put "/wiki/#{ page.id }",
|
||||||
|
params: {
|
||||||
|
title: 'wiki_conflict_request',
|
||||||
|
body: 'third',
|
||||||
|
message: 'stale',
|
||||||
|
base_revision_id: stale_id,
|
||||||
|
},
|
||||||
|
headers: auth_headers(user)
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:conflict)
|
||||||
|
|
||||||
|
page.reload
|
||||||
|
expect(page.body).to eq('second')
|
||||||
|
expect(page.current_revision.message).to eq('other edit')
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
require 'cgi'
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe 'Wiki history integrity', type: :request do
|
||||||
|
let!(:user) { create_member_user! }
|
||||||
|
|
||||||
|
def auth_headers user
|
||||||
|
{ 'X-Transfer-Code' => user.inheritance_code }
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_wiki_page title:, body: 'body', message: 'init', user: self.user
|
||||||
|
Wiki::Commit.create_content!(
|
||||||
|
tag_name: TagName.create!(name: title),
|
||||||
|
body:,
|
||||||
|
created_by_user: user,
|
||||||
|
message:)
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'POST /wiki' do
|
||||||
|
it 'creates wiki_page, wiki_revision, and wiki_version atomically' do
|
||||||
|
expect {
|
||||||
|
post '/wiki',
|
||||||
|
params: {
|
||||||
|
title: 'wiki_history_create_atomic',
|
||||||
|
body: "a\nb\nc",
|
||||||
|
message: 'initial commit',
|
||||||
|
},
|
||||||
|
headers: auth_headers(user)
|
||||||
|
}
|
||||||
|
.to change(WikiPage, :count).by(1)
|
||||||
|
.and change(WikiRevision, :count).by(1)
|
||||||
|
.and change(WikiVersion, :count).by(1)
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:created)
|
||||||
|
|
||||||
|
page = WikiPage.find(json.fetch('id'))
|
||||||
|
revision = page.current_revision
|
||||||
|
version = page.wiki_versions.order(:version_no).last
|
||||||
|
|
||||||
|
expect(page.title).to eq('wiki_history_create_atomic')
|
||||||
|
expect(page.body).to eq("a\nb\nc")
|
||||||
|
|
||||||
|
expect(revision).to be_content
|
||||||
|
expect(revision.message).to eq('initial commit')
|
||||||
|
expect(revision.lines_count).to eq(3)
|
||||||
|
|
||||||
|
expect(version).to have_attributes(
|
||||||
|
version_no: 1,
|
||||||
|
event_type: 'create',
|
||||||
|
title: 'wiki_history_create_atomic',
|
||||||
|
body: "a\nb\nc",
|
||||||
|
reason: 'initial commit',
|
||||||
|
created_by_user_id: user.id
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns 422 and creates nothing when normalised body is blank' do
|
||||||
|
expect {
|
||||||
|
post '/wiki',
|
||||||
|
params: {
|
||||||
|
title: 'wiki_history_blank_body',
|
||||||
|
body: "\r\n\r\n",
|
||||||
|
message: 'blank',
|
||||||
|
},
|
||||||
|
headers: auth_headers(user)
|
||||||
|
}
|
||||||
|
.not_to change(WikiPage, :count)
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:unprocessable_entity)
|
||||||
|
expect(WikiPage.joins(:tag_name).where(tag_names: { name: 'wiki_history_blank_body' })).not_to exist
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns 422 and creates no partial page when title already exists' do
|
||||||
|
create_wiki_page(title: 'wiki_history_duplicate_title', body: 'first')
|
||||||
|
|
||||||
|
expect {
|
||||||
|
post '/wiki',
|
||||||
|
params: {
|
||||||
|
title: 'wiki_history_duplicate_title',
|
||||||
|
body: 'second',
|
||||||
|
message: 'duplicate',
|
||||||
|
},
|
||||||
|
headers: auth_headers(user)
|
||||||
|
}
|
||||||
|
.not_to change(WikiPage, :count)
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:unprocessable_entity)
|
||||||
|
expect(WikiPage.joins(:tag_name).where(tag_names: { name: 'wiki_history_duplicate_title' }).count).to eq(1)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'PUT /wiki/:id' do
|
||||||
|
it 'updates body and records wiki_revision and wiki_version' do
|
||||||
|
page = create_wiki_page(title: 'wiki_history_update_body', body: 'before')
|
||||||
|
current_id = page.current_revision.id
|
||||||
|
|
||||||
|
expect {
|
||||||
|
put "/wiki/#{ page.id }",
|
||||||
|
params: {
|
||||||
|
title: 'wiki_history_update_body',
|
||||||
|
body: 'after',
|
||||||
|
message: 'edit body',
|
||||||
|
base_revision_id: current_id,
|
||||||
|
},
|
||||||
|
headers: auth_headers(user)
|
||||||
|
}
|
||||||
|
.to change(WikiRevision, :count).by(1)
|
||||||
|
.and change(WikiVersion, :count).by(1)
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
|
||||||
|
page.reload
|
||||||
|
version = page.wiki_versions.order(:version_no).last
|
||||||
|
|
||||||
|
expect(page.title).to eq('wiki_history_update_body')
|
||||||
|
expect(page.body).to eq('after')
|
||||||
|
|
||||||
|
expect(version).to have_attributes(
|
||||||
|
event_type: 'update',
|
||||||
|
title: 'wiki_history_update_body',
|
||||||
|
body: 'after',
|
||||||
|
reason: 'edit body',
|
||||||
|
created_by_user_id: user.id
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'renames title and records wiki_version with new title' do
|
||||||
|
page = create_wiki_page(title: 'wiki_history_rename_before', body: 'before')
|
||||||
|
current_id = page.current_revision.id
|
||||||
|
|
||||||
|
expect {
|
||||||
|
put "/wiki/#{ page.id }",
|
||||||
|
params: {
|
||||||
|
title: 'wiki_history_rename_after',
|
||||||
|
body: 'after',
|
||||||
|
message: 'rename',
|
||||||
|
base_revision_id: current_id,
|
||||||
|
},
|
||||||
|
headers: auth_headers(user)
|
||||||
|
}
|
||||||
|
.to change(WikiRevision, :count).by(1)
|
||||||
|
.and change(WikiVersion, :count).by(1)
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
|
||||||
|
page.reload
|
||||||
|
version = page.wiki_versions.order(:version_no).last
|
||||||
|
|
||||||
|
expect(page.title).to eq('wiki_history_rename_after')
|
||||||
|
expect(page.body).to eq('after')
|
||||||
|
|
||||||
|
expect(version).to have_attributes(
|
||||||
|
event_type: 'update',
|
||||||
|
title: 'wiki_history_rename_after',
|
||||||
|
body: 'after',
|
||||||
|
reason: 'rename',
|
||||||
|
created_by_user_id: user.id
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'does not change title, body, revision, or version on stale base_revision_id' do
|
||||||
|
page = create_wiki_page(title: 'wiki_history_conflict_page', body: 'first')
|
||||||
|
stale_id = page.current_revision.id
|
||||||
|
|
||||||
|
Wiki::Commit.content!(
|
||||||
|
page:,
|
||||||
|
body: 'second',
|
||||||
|
created_user: user,
|
||||||
|
message: 'other edit',
|
||||||
|
base_revision_id: stale_id)
|
||||||
|
|
||||||
|
page.reload
|
||||||
|
current_title = page.title
|
||||||
|
current_body = page.body
|
||||||
|
revision_count = page.wiki_revisions.count
|
||||||
|
version_count = page.wiki_versions.count
|
||||||
|
|
||||||
|
put "/wiki/#{ page.id }",
|
||||||
|
params: {
|
||||||
|
title: 'wiki_history_conflict_renamed',
|
||||||
|
body: 'third',
|
||||||
|
message: 'stale edit',
|
||||||
|
base_revision_id: stale_id,
|
||||||
|
},
|
||||||
|
headers: auth_headers(user)
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:conflict)
|
||||||
|
|
||||||
|
page.reload
|
||||||
|
expect(page.title).to eq(current_title)
|
||||||
|
expect(page.body).to eq(current_body)
|
||||||
|
expect(page.wiki_revisions.count).to eq(revision_count)
|
||||||
|
expect(page.wiki_versions.count).to eq(version_count)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe 'Wiki restore', type: :request do
|
||||||
|
let!(:user) { create_member_user! }
|
||||||
|
|
||||||
|
def auth_headers user
|
||||||
|
{ 'X-Transfer-Code' => user.inheritance_code }
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'restores wiki page to previous version' do
|
||||||
|
pending '#337 で対応予定'
|
||||||
|
|
||||||
|
page =
|
||||||
|
Wiki::Commit.create_content!(
|
||||||
|
tag_name: TagName.create!(name: 'wiki_restore_page'),
|
||||||
|
body: 'v1',
|
||||||
|
created_by_user: user,
|
||||||
|
message: 'init')
|
||||||
|
|
||||||
|
v1 = page.wiki_versions.order(:version_no).last
|
||||||
|
|
||||||
|
Wiki::Commit.content!(
|
||||||
|
page:,
|
||||||
|
body: 'v2',
|
||||||
|
created_user: user,
|
||||||
|
message: 'edit',
|
||||||
|
base_revision_id: page.current_revision.id)
|
||||||
|
|
||||||
|
post "/wiki/#{ page.id }/restore",
|
||||||
|
params: { version_no: v1.version_no },
|
||||||
|
headers: auth_headers(user)
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(page.reload.body).to eq('v1')
|
||||||
|
expect(page.wiki_versions.order(:version_no).last.event_type).to eq('restore')
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -4,13 +4,19 @@ require 'securerandom'
|
|||||||
|
|
||||||
|
|
||||||
RSpec.describe 'Wiki API', type: :request do
|
RSpec.describe 'Wiki API', type: :request do
|
||||||
|
def auth_headers(user)
|
||||||
|
{ 'X-Transfer-Code' => user.inheritance_code }
|
||||||
|
end
|
||||||
|
|
||||||
let!(:user) { create_member_user! }
|
let!(:user) { create_member_user! }
|
||||||
|
|
||||||
let!(:tn) { TagName.create!(name: 'spec_wiki_title') }
|
let!(:tn) { TagName.create!(name: 'spec_wiki_title') }
|
||||||
let!(:page) do
|
let!(:page) do
|
||||||
WikiPage.create!(tag_name: tn, created_user: user, updated_user: user).tap do |p|
|
Wiki::Commit.create_content!(
|
||||||
Wiki::Commit.content!(page: p, body: 'init', created_user: user, message: 'init')
|
tag_name: tn,
|
||||||
end
|
body: 'init',
|
||||||
|
created_by_user: user,
|
||||||
|
message: 'init')
|
||||||
end
|
end
|
||||||
|
|
||||||
describe 'GET /wiki' do
|
describe 'GET /wiki' do
|
||||||
@@ -37,11 +43,12 @@ RSpec.describe 'Wiki API', type: :request do
|
|||||||
context 'when wiki page exists' do
|
context 'when wiki page exists' do
|
||||||
it 'returns wiki page with title' do
|
it 'returns wiki page with title' do
|
||||||
request
|
request
|
||||||
|
|
||||||
expect(response).to have_http_status(:ok)
|
expect(response).to have_http_status(:ok)
|
||||||
|
|
||||||
expect(json).to include(
|
expect(json).to include(
|
||||||
'id' => page.id,
|
'id' => page.id,
|
||||||
'title' => 'spec_wiki_title')
|
'title' => 'spec_wiki_title')
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -50,6 +57,7 @@ RSpec.describe 'Wiki API', type: :request do
|
|||||||
|
|
||||||
it 'returns 404' do
|
it 'returns 404' do
|
||||||
request
|
request
|
||||||
|
|
||||||
expect(response).to have_http_status(:not_found)
|
expect(response).to have_http_status(:not_found)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -99,25 +107,34 @@ RSpec.describe 'Wiki API', type: :request do
|
|||||||
end
|
end
|
||||||
.to change(WikiPage, :count).by(1)
|
.to change(WikiPage, :count).by(1)
|
||||||
.and change(WikiRevision, :count).by(1)
|
.and change(WikiRevision, :count).by(1)
|
||||||
|
.and change(WikiVersion, :count).by(1)
|
||||||
|
|
||||||
expect(response).to have_http_status(:created)
|
expect(response).to have_http_status(:created)
|
||||||
|
|
||||||
page_id = json.fetch('id')
|
page_id = json.fetch('id')
|
||||||
expect(json.fetch('title')).to eq('TestPage')
|
expect(json.fetch('title')).to eq('TestPage')
|
||||||
|
|
||||||
page = WikiPage.find(page_id)
|
created_page = WikiPage.find(page_id)
|
||||||
rev = page.current_revision
|
version = created_page.wiki_versions.order(:version_no).last
|
||||||
|
|
||||||
|
expect(version).to have_attributes(
|
||||||
|
version_no: 1,
|
||||||
|
event_type: 'create',
|
||||||
|
title: 'TestPage',
|
||||||
|
body: "a\nb\nc",
|
||||||
|
created_by_user_id: member.id
|
||||||
|
)
|
||||||
|
|
||||||
|
rev = created_page.current_revision
|
||||||
expect(rev).to be_present
|
expect(rev).to be_present
|
||||||
expect(rev).to be_content
|
expect(rev).to be_content
|
||||||
expect(rev.message).to eq('init')
|
expect(rev.message).to eq('init')
|
||||||
|
|
||||||
# body が復元できること
|
expect(created_page.body).to eq("a\nb\nc")
|
||||||
expect(page.body).to eq("a\nb\nc")
|
|
||||||
|
|
||||||
# 行数とリレーションの整合
|
|
||||||
expect(rev.lines_count).to eq(3)
|
expect(rev.lines_count).to eq(3)
|
||||||
expect(rev.wiki_revision_lines.order(:position).pluck(:position)).to eq([0, 1, 2])
|
expect(rev.wiki_revision_lines.order(:position).pluck(:position)).to eq([0, 1, 2])
|
||||||
expect(rev.wiki_lines.pluck(:body)).to match_array(%w[a b c])
|
expect(rev.wiki_lines.pluck(:body)).to match_array(['a', 'b', 'c'])
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'reuses existing WikiLine rows by sha256' do
|
it 'reuses existing WikiLine rows by sha256' do
|
||||||
@@ -135,6 +152,41 @@ RSpec.describe 'Wiki API', type: :request do
|
|||||||
# "a" の WikiLine が増殖しない(1行のはず)
|
# "a" の WikiLine が増殖しない(1行のはず)
|
||||||
expect(WikiLine.where(body: 'a').count).to eq(1)
|
expect(WikiLine.where(body: 'a').count).to eq(1)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'deduplicates duplicated new lines before upsert' do
|
||||||
|
duplicated = 'duplicated_line_for_wiki_line_upsert_spec'
|
||||||
|
|
||||||
|
post endpoint,
|
||||||
|
params: { title: 'DuplicateNewLine', body: "#{ duplicated }\n#{ duplicated }" },
|
||||||
|
headers: auth_headers(member)
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:created)
|
||||||
|
|
||||||
|
page = WikiPage.find(json.fetch('id'))
|
||||||
|
rev = page.current_revision
|
||||||
|
|
||||||
|
expect(rev.lines_count).to eq(2)
|
||||||
|
expect(WikiLine.where(body: duplicated).count).to eq(1)
|
||||||
|
expect(rev.wiki_revision_lines.count).to eq(2)
|
||||||
|
expect(rev.wiki_revision_lines.pluck(:wiki_line_id).uniq.size).to eq(1)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'normalises CRLF and strips trailing newlines' do
|
||||||
|
post endpoint,
|
||||||
|
params: { title: 'NormalisedBody', body: "a\r\nb\r\n\r\n", message: 'normalise' },
|
||||||
|
headers: auth_headers(member)
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:created)
|
||||||
|
|
||||||
|
page = WikiPage.find(json.fetch('id'))
|
||||||
|
rev = page.current_revision
|
||||||
|
version = page.wiki_versions.order(:version_no).last
|
||||||
|
|
||||||
|
expect(page.body).to eq("a\nb")
|
||||||
|
expect(version.body).to eq("a\nb")
|
||||||
|
expect(rev.lines_count).to eq(2)
|
||||||
|
expect(rev.wiki_lines.order('wiki_revision_lines.position').map(&:body)).to eq(['a', 'b'])
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -146,17 +198,14 @@ RSpec.describe 'Wiki API', type: :request do
|
|||||||
{ 'X-Transfer-Code' => user.inheritance_code }
|
{ 'X-Transfer-Code' => user.inheritance_code }
|
||||||
end
|
end
|
||||||
|
|
||||||
#let!(:page) { create(:wiki_page, title: 'TestPage') }
|
let!(:test_tag_name) { TagName.create!(name: 'TestPage') }
|
||||||
let!(:page) do
|
|
||||||
build(:wiki_page, title: 'TestPage').tap do |p|
|
|
||||||
puts p.errors.full_messages unless p.valid?
|
|
||||||
p.save!
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
before do
|
let!(:page) do
|
||||||
# 初期版を 1 つ作っておく(更新が“2版目”になるように)
|
Wiki::Commit.create_content!(
|
||||||
Wiki::Commit.content!(page: page, body: "a\nb", created_user: member, message: 'init')
|
tag_name: test_tag_name,
|
||||||
|
body: "a\nb",
|
||||||
|
created_by_user: member,
|
||||||
|
message: 'init')
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'when not logged in' do
|
context 'when not logged in' do
|
||||||
@@ -182,14 +231,6 @@ RSpec.describe 'Wiki API', type: :request do
|
|||||||
headers: auth_headers(member)
|
headers: auth_headers(member)
|
||||||
expect(response).to have_http_status(:unprocessable_entity)
|
expect(response).to have_http_status(:unprocessable_entity)
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'returns 422 when title mismatched (if you forbid rename here)' do
|
|
||||||
put "/wiki/#{page.id}",
|
|
||||||
params: { title: 'OtherTitle', body: 'x' },
|
|
||||||
headers: auth_headers(member)
|
|
||||||
# 君の controller 例だと title 変更は 422 にしてた
|
|
||||||
expect(response).to have_http_status(:unprocessable_entity)
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'when success' do
|
context 'when success' do
|
||||||
@@ -200,7 +241,18 @@ RSpec.describe 'Wiki API', type: :request do
|
|||||||
put "/wiki/#{page.id}",
|
put "/wiki/#{page.id}",
|
||||||
params: { title: 'TestPage', body: "x\ny", message: 'edit', base_revision_id: current_id },
|
params: { title: 'TestPage', body: "x\ny", message: 'edit', base_revision_id: current_id },
|
||||||
headers: auth_headers(member)
|
headers: auth_headers(member)
|
||||||
end.to change(WikiRevision, :count).by(1)
|
end
|
||||||
|
.to change(WikiRevision, :count).by(1)
|
||||||
|
.and change(WikiVersion, :count).by(1)
|
||||||
|
|
||||||
|
version = page.wiki_versions.order(:version_no).last
|
||||||
|
|
||||||
|
expect(version).to have_attributes(
|
||||||
|
event_type: 'update',
|
||||||
|
title: 'TestPage',
|
||||||
|
body: "x\ny",
|
||||||
|
created_by_user_id: member.id
|
||||||
|
)
|
||||||
|
|
||||||
expect(response).to have_http_status(:ok)
|
expect(response).to have_http_status(:ok)
|
||||||
|
|
||||||
@@ -211,25 +263,60 @@ RSpec.describe 'Wiki API', type: :request do
|
|||||||
expect(page.body).to eq("x\ny")
|
expect(page.body).to eq("x\ny")
|
||||||
expect(rev.base_revision_id).to eq(current_id)
|
expect(rev.base_revision_id).to eq(current_id)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'wiki body だけを変更しても tag version は作成しない' do
|
||||||
|
linked_tag_name = TagName.create!(name: 'wiki_body_only_tag')
|
||||||
|
linked_tag = Tag.create!(tag_name: linked_tag_name, category: :general)
|
||||||
|
|
||||||
|
TagVersionRecorder.record!(
|
||||||
|
tag: linked_tag,
|
||||||
|
event_type: :create,
|
||||||
|
created_by_user: member)
|
||||||
|
|
||||||
|
linked_page =
|
||||||
|
Wiki::Commit.create_content!(
|
||||||
|
tag_name: linked_tag_name,
|
||||||
|
body: 'before',
|
||||||
|
created_by_user: member,
|
||||||
|
message: 'init')
|
||||||
|
|
||||||
|
current_id = linked_page.current_revision.id
|
||||||
|
before_count = linked_tag.reload.tag_versions.count
|
||||||
|
|
||||||
|
expect {
|
||||||
|
put "/wiki/#{ linked_page.id }",
|
||||||
|
params: {
|
||||||
|
title: 'wiki_body_only_tag',
|
||||||
|
body: 'after',
|
||||||
|
message: 'edit',
|
||||||
|
base_revision_id: current_id,
|
||||||
|
},
|
||||||
|
headers: auth_headers(member)
|
||||||
|
}
|
||||||
|
.to change(WikiRevision, :count).by(1)
|
||||||
|
.and change(WikiVersion, :count).by(1)
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(linked_tag.reload.tag_versions.count).to eq(before_count)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# TODO: コンフリクト未実装のため,実装したらコメント外す.
|
context 'when conflict' do
|
||||||
# context 'when conflict' do
|
it 'returns 409 when base_revision_id mismatches' do
|
||||||
# it 'returns 409 when base_revision_id mismatches' do
|
# 先に別ユーザ(同じ member でもOK)が 1 回更新して先頭を進める
|
||||||
# # 先に別ユーザ(同じ member でもOK)が 1 回更新して先頭を進める
|
Wiki::Commit.content!(page: page, body: "zzz", created_user: member, message: 'other edit')
|
||||||
# Wiki::Commit.content!(page: page, body: "zzz", created_user: member, message: 'other edit')
|
page.reload
|
||||||
# page.reload
|
|
||||||
|
|
||||||
# stale_id = page.wiki_revisions.order(:id).first.id # わざと古い id
|
stale_id = page.wiki_revisions.order(:id).first.id # わざと古い id
|
||||||
# put "/wiki/#{page.id}",
|
put "/wiki/#{page.id}",
|
||||||
# params: { title: 'TestPage', body: 'x', base_revision_id: stale_id },
|
params: { title: 'TestPage', body: 'x', base_revision_id: stale_id },
|
||||||
# headers: auth_headers(member)
|
headers: auth_headers(member)
|
||||||
|
|
||||||
# expect(response).to have_http_status(:conflict)
|
expect(response).to have_http_status(:conflict)
|
||||||
# json = JSON.parse(response.body)
|
json = JSON.parse(response.body)
|
||||||
# expect(json['error']).to eq('conflict')
|
expect(json['error']).to eq('conflict')
|
||||||
# end
|
end
|
||||||
# end
|
end
|
||||||
|
|
||||||
context 'when page not found' do
|
context 'when page not found' do
|
||||||
it 'returns 404' do
|
it 'returns 404' do
|
||||||
@@ -261,14 +348,17 @@ RSpec.describe 'Wiki API', type: :request do
|
|||||||
|
|
||||||
describe 'GET /wiki/search' do
|
describe 'GET /wiki/search' do
|
||||||
before do
|
before do
|
||||||
# 追加で検索ヒット用
|
Wiki::Commit.create_content!(
|
||||||
TagName.create!(name: 'spec_wiki_title_2')
|
tag_name: TagName.create!(name: 'spec_wiki_title_2'),
|
||||||
WikiPage.create!(tag_name: TagName.find_by!(name: 'spec_wiki_title_2'),
|
body: 'search body 2',
|
||||||
created_user: user, updated_user: user)
|
created_by_user: user,
|
||||||
|
message: 'init')
|
||||||
|
|
||||||
TagName.create!(name: 'unrelated_title')
|
Wiki::Commit.create_content!(
|
||||||
WikiPage.create!(tag_name: TagName.find_by!(name: 'unrelated_title'),
|
tag_name: TagName.create!(name: 'unrelated_title'),
|
||||||
created_user: user, updated_user: user)
|
body: 'unrelated body',
|
||||||
|
created_by_user: user,
|
||||||
|
message: 'init')
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'returns up to 20 pages filtered by title like' do
|
it 'returns up to 20 pages filtered by title like' do
|
||||||
@@ -278,7 +368,9 @@ RSpec.describe 'Wiki API', type: :request do
|
|||||||
expect(json).to be_an(Array)
|
expect(json).to be_an(Array)
|
||||||
|
|
||||||
titles = json.map { |p| p['title'] }
|
titles = json.map { |p| p['title'] }
|
||||||
expect(titles).to include('spec_wiki_title', 'spec_wiki_title_2')
|
|
||||||
|
expect(titles).to include('spec_wiki_title')
|
||||||
|
expect(titles).to include('spec_wiki_title_2')
|
||||||
expect(titles).not_to include('unrelated_title')
|
expect(titles).not_to include('unrelated_title')
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -329,7 +421,12 @@ RSpec.describe 'Wiki API', type: :request do
|
|||||||
it 'returns empty array when page has no revisions and filtered by id' do
|
it 'returns empty array when page has no revisions and filtered by id' do
|
||||||
# 別ページを作って revision 無し
|
# 別ページを作って revision 無し
|
||||||
tn2 = TagName.create!(name: 'spec_no_rev')
|
tn2 = TagName.create!(name: 'spec_no_rev')
|
||||||
p2 = WikiPage.create!(tag_name: tn2, created_user: user, updated_user: user)
|
# 異常データ: revision 無し WikiPage を直接作る
|
||||||
|
p2 = WikiPage.create!(
|
||||||
|
tag_name: tn2,
|
||||||
|
body: 'init',
|
||||||
|
created_user: user,
|
||||||
|
updated_user: user)
|
||||||
|
|
||||||
get "/wiki/changes?id=#{p2.id}"
|
get "/wiki/changes?id=#{p2.id}"
|
||||||
expect(response).to have_http_status(:ok)
|
expect(response).to have_http_status(:ok)
|
||||||
@@ -398,29 +495,68 @@ RSpec.describe 'Wiki API', type: :request do
|
|||||||
expect(json['older_revision_id']).to eq(rev_a.id)
|
expect(json['older_revision_id']).to eq(rev_a.id)
|
||||||
expect(json['newer_revision_id']).to eq(page.current_revision.id)
|
expect(json['newer_revision_id']).to eq(page.current_revision.id)
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|
||||||
it 'returns 422 when "to" is redirect revision' do
|
describe 'Wiki::Commit.redirect!' do
|
||||||
# redirect revision を作る
|
it 'raises because redirect revisions are deprecated' do
|
||||||
tn2 = TagName.create!(name: 'redirect_target')
|
target_tag_name = TagName.create!(name: 'redirect_deprecated_target')
|
||||||
target = WikiPage.create!(tag_name: tn2, created_user: user, updated_user: user)
|
target =
|
||||||
|
Wiki::Commit.create_content!(
|
||||||
|
tag_name: target_tag_name,
|
||||||
|
body: 'target',
|
||||||
|
created_by_user: user,
|
||||||
|
message: 'init')
|
||||||
|
|
||||||
Wiki::Commit.redirect!(page: page, redirect_page: target, created_user: user, message: 'redir')
|
expect {
|
||||||
redirect_rev = page.current_revision
|
Wiki::Commit.redirect!(
|
||||||
expect(redirect_rev).to be_redirect
|
page: page,
|
||||||
|
redirect_page: target,
|
||||||
get "/wiki/#{page.id}/diff?from=#{rev_a.id}&to=#{redirect_rev.id}"
|
created_user: user,
|
||||||
expect(response).to have_http_status(:unprocessable_entity)
|
message: 'redirect',
|
||||||
end
|
base_revision_id: page.current_revision.id
|
||||||
|
)
|
||||||
it 'returns 422 when "from" is redirect revision' do
|
}.to raise_error(RuntimeError, '廃止しました.')
|
||||||
tn2 = TagName.create!(name: 'redirect_target2')
|
|
||||||
target = WikiPage.create!(tag_name: tn2, created_user: user, updated_user: user)
|
|
||||||
|
|
||||||
Wiki::Commit.redirect!(page: page, redirect_page: target, created_user: user, message: 'redir2')
|
|
||||||
redirect_rev = page.current_revision
|
|
||||||
|
|
||||||
get "/wiki/#{page.id}/diff?from=#{redirect_rev.id}&to=#{rev_b.id}"
|
|
||||||
expect(response).to have_http_status(:unprocessable_entity)
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'wiki title を変更すると対応する tag の version を作成する' do
|
||||||
|
linked_tag_name = TagName.create!(name: 'wiki_linked_tag_for_version')
|
||||||
|
linked_tag = Tag.create!(tag_name: linked_tag_name, category: :general)
|
||||||
|
|
||||||
|
linked_page =
|
||||||
|
Wiki::Commit.create_content!(
|
||||||
|
tag_name: linked_tag_name,
|
||||||
|
body: 'before',
|
||||||
|
created_by_user: user,
|
||||||
|
message: 'init')
|
||||||
|
|
||||||
|
current_id = linked_page.current_revision.id
|
||||||
|
|
||||||
|
expect {
|
||||||
|
put "/wiki/#{ linked_page.id }",
|
||||||
|
params: {
|
||||||
|
title: 'wiki_linked_tag_for_version_renamed',
|
||||||
|
body: 'after',
|
||||||
|
message: 'rename',
|
||||||
|
base_revision_id: current_id,
|
||||||
|
},
|
||||||
|
headers: auth_headers(user)
|
||||||
|
}
|
||||||
|
.to change(WikiRevision, :count).by(1)
|
||||||
|
.and change(WikiVersion, :count).by(1)
|
||||||
|
.and change { linked_tag.reload.tag_versions.count }.by(2)
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
|
||||||
|
linked_tag.reload
|
||||||
|
expect(linked_tag.name).to eq('wiki_linked_tag_for_version_renamed')
|
||||||
|
|
||||||
|
versions = linked_tag.tag_versions.order(:version_no)
|
||||||
|
|
||||||
|
expect(versions.first.event_type).to eq('create')
|
||||||
|
expect(versions.first.name).to eq('wiki_linked_tag_for_version')
|
||||||
|
|
||||||
|
expect(versions.second.event_type).to eq('update')
|
||||||
|
expect(versions.second.name).to eq('wiki_linked_tag_for_version_renamed')
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe 'Wiki title collision', type: :request do
|
||||||
|
let!(:user) { create_member_user! }
|
||||||
|
|
||||||
|
def auth_headers user
|
||||||
|
{ 'X-Transfer-Code' => user.inheritance_code }
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_wiki_page title:, body:
|
||||||
|
Wiki::Commit.create_content!(
|
||||||
|
tag_name: TagName.create!(name: title),
|
||||||
|
body:,
|
||||||
|
created_by_user: user,
|
||||||
|
message: 'init')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns 422 when renaming wiki title to existing title' do
|
||||||
|
source = create_wiki_page(title: 'wiki_collision_source', body: 'source body')
|
||||||
|
create_wiki_page(title: 'wiki_collision_target', body: 'target body')
|
||||||
|
|
||||||
|
source_revision_count = source.wiki_revisions.count
|
||||||
|
source_version_count = source.wiki_versions.count
|
||||||
|
old_title = source.title
|
||||||
|
old_body = source.body
|
||||||
|
|
||||||
|
put "/wiki/#{ source.id }",
|
||||||
|
params: {
|
||||||
|
title: 'wiki_collision_target',
|
||||||
|
body: 'new body',
|
||||||
|
message: 'rename collision',
|
||||||
|
base_revision_id: source.current_revision.id,
|
||||||
|
},
|
||||||
|
headers: auth_headers(user)
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:unprocessable_entity)
|
||||||
|
|
||||||
|
source.reload
|
||||||
|
|
||||||
|
expect(source.title).to eq(old_title)
|
||||||
|
expect(source.body).to eq(old_body)
|
||||||
|
expect(source.wiki_revisions.count).to eq(source_revision_count)
|
||||||
|
expect(source.wiki_versions.count).to eq(source_version_count)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns 422 when creating wiki with existing title' do
|
||||||
|
create_wiki_page(title: 'wiki_collision_create', body: 'already exists')
|
||||||
|
|
||||||
|
expect {
|
||||||
|
post '/wiki',
|
||||||
|
params: {
|
||||||
|
title: 'wiki_collision_create',
|
||||||
|
body: 'new body',
|
||||||
|
message: 'duplicate create',
|
||||||
|
},
|
||||||
|
headers: auth_headers(user)
|
||||||
|
}
|
||||||
|
.not_to change(WikiPage, :count)
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:unprocessable_entity)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
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
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
require 'digest'
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe Wiki::Commit do
|
||||||
|
let(:user) { create_member_user! }
|
||||||
|
|
||||||
|
def create_page title:, body: 'initial body'
|
||||||
|
described_class.create_content!(
|
||||||
|
tag_name: TagName.create!(name: title),
|
||||||
|
body:,
|
||||||
|
created_by_user: user,
|
||||||
|
message: 'init')
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '.create_content!' do
|
||||||
|
it 'creates page, revision, and version with normalised body' do
|
||||||
|
expect {
|
||||||
|
described_class.create_content!(
|
||||||
|
tag_name: TagName.create!(name: 'commit_integrity_create'),
|
||||||
|
body: "a\r\nb\r\n\r\n",
|
||||||
|
created_by_user: user,
|
||||||
|
message: 'init')
|
||||||
|
}
|
||||||
|
.to change(WikiPage, :count).by(1)
|
||||||
|
.and change(WikiRevision, :count).by(1)
|
||||||
|
.and change(WikiVersion, :count).by(1)
|
||||||
|
|
||||||
|
page = WikiPage.joins(:tag_name).find_by!(tag_names: { name: 'commit_integrity_create' })
|
||||||
|
revision = page.current_revision
|
||||||
|
version = page.wiki_versions.order(:version_no).last
|
||||||
|
|
||||||
|
expect(page.body).to eq("a\nb")
|
||||||
|
expect(revision.lines_count).to eq(2)
|
||||||
|
expect(version.body).to eq("a\nb")
|
||||||
|
expect(version.reason).to eq('init')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'rejects body that becomes blank after normalisation' do
|
||||||
|
tag_name = TagName.create!(name: 'commit_integrity_blank')
|
||||||
|
|
||||||
|
expect {
|
||||||
|
described_class.create_content!(
|
||||||
|
tag_name:,
|
||||||
|
body: "\r\n\r\n",
|
||||||
|
created_by_user: user,
|
||||||
|
message: 'blank')
|
||||||
|
}
|
||||||
|
.to raise_error(ActiveRecord::RecordInvalid)
|
||||||
|
|
||||||
|
expect(WikiPage.where(tag_name:)).not_to exist
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '.content!' do
|
||||||
|
it 'updates page body, revision, and version' do
|
||||||
|
page = create_page(title: 'commit_integrity_update', body: 'before')
|
||||||
|
current_id = page.current_revision.id
|
||||||
|
|
||||||
|
expect {
|
||||||
|
described_class.content!(
|
||||||
|
page:,
|
||||||
|
body: 'after',
|
||||||
|
created_user: user,
|
||||||
|
message: 'edit',
|
||||||
|
base_revision_id: current_id)
|
||||||
|
}
|
||||||
|
.to change(WikiRevision, :count).by(1)
|
||||||
|
.and change(WikiVersion, :count).by(1)
|
||||||
|
|
||||||
|
page.reload
|
||||||
|
version = page.wiki_versions.order(:version_no).last
|
||||||
|
|
||||||
|
expect(page.body).to eq('after')
|
||||||
|
expect(version.body).to eq('after')
|
||||||
|
expect(version.reason).to eq('edit')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'does not record tag_version on body-only wiki update' do
|
||||||
|
tag_name = TagName.create!(name: 'commit_integrity_linked_tag')
|
||||||
|
tag = Tag.create!(tag_name:, category: :general)
|
||||||
|
|
||||||
|
page =
|
||||||
|
described_class.create_content!(
|
||||||
|
tag_name:,
|
||||||
|
body: 'before',
|
||||||
|
created_by_user: user,
|
||||||
|
message: 'init')
|
||||||
|
|
||||||
|
TagVersionRecorder.record!(
|
||||||
|
tag:,
|
||||||
|
event_type: :create,
|
||||||
|
created_by_user: user)
|
||||||
|
|
||||||
|
before_count = tag.reload.tag_versions.count
|
||||||
|
|
||||||
|
described_class.content!(
|
||||||
|
page:,
|
||||||
|
body: 'after',
|
||||||
|
created_user: user,
|
||||||
|
message: 'edit',
|
||||||
|
base_revision_id: page.current_revision.id)
|
||||||
|
|
||||||
|
expect(tag.reload.tag_versions.count).to eq(before_count)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'raises conflict and leaves page, revision, and version unchanged' do
|
||||||
|
page = create_page(title: 'commit_integrity_conflict', body: 'first')
|
||||||
|
stale_id = page.current_revision.id
|
||||||
|
|
||||||
|
described_class.content!(
|
||||||
|
page:,
|
||||||
|
body: 'second',
|
||||||
|
created_user: user,
|
||||||
|
message: 'second',
|
||||||
|
base_revision_id: stale_id)
|
||||||
|
|
||||||
|
page.reload
|
||||||
|
before_body = page.body
|
||||||
|
before_revision_count = page.wiki_revisions.count
|
||||||
|
before_version_count = page.wiki_versions.count
|
||||||
|
|
||||||
|
expect {
|
||||||
|
described_class.content!(
|
||||||
|
page:,
|
||||||
|
body: 'third',
|
||||||
|
created_user: user,
|
||||||
|
message: 'stale',
|
||||||
|
base_revision_id: stale_id)
|
||||||
|
}
|
||||||
|
.to raise_error(Wiki::Commit::Conflict)
|
||||||
|
|
||||||
|
page.reload
|
||||||
|
expect(page.body).to eq(before_body)
|
||||||
|
expect(page.wiki_revisions.count).to eq(before_revision_count)
|
||||||
|
expect(page.wiki_versions.count).to eq(before_version_count)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'deduplicates duplicated missing wiki lines' do
|
||||||
|
page = create_page(title: 'commit_integrity_dedup', body: 'before')
|
||||||
|
duplicated = 'commit_integrity_duplicate_line'
|
||||||
|
|
||||||
|
described_class.content!(
|
||||||
|
page:,
|
||||||
|
body: "#{ duplicated }\n#{ duplicated }",
|
||||||
|
created_user: user,
|
||||||
|
message: 'dedup',
|
||||||
|
base_revision_id: page.current_revision.id)
|
||||||
|
|
||||||
|
revision = page.reload.current_revision
|
||||||
|
|
||||||
|
expect(WikiLine.where(body: duplicated).count).to eq(1)
|
||||||
|
expect(revision.wiki_revision_lines.count).to eq(2)
|
||||||
|
expect(revision.wiki_revision_lines.pluck(:wiki_line_id).uniq.size).to eq(1)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '.redirect!' do
|
||||||
|
it 'raises because redirect revisions are deprecated' do
|
||||||
|
page = create_page(title: 'commit_integrity_redirect_source', body: 'source')
|
||||||
|
target = create_page(title: 'commit_integrity_redirect_target', body: 'target')
|
||||||
|
|
||||||
|
expect {
|
||||||
|
described_class.redirect!(
|
||||||
|
page:,
|
||||||
|
redirect_page: target,
|
||||||
|
created_user: user,
|
||||||
|
message: 'redirect',
|
||||||
|
base_revision_id: page.current_revision.id)
|
||||||
|
}
|
||||||
|
.to raise_error(RuntimeError, '廃止しました.')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe Wiki::Commit do
|
||||||
|
let(:user) { create_member_user! }
|
||||||
|
|
||||||
|
def create_page(title: 'commit_spec_page', body: 'initial body')
|
||||||
|
tag_name = TagName.create!(name: title)
|
||||||
|
|
||||||
|
Wiki::Commit.create_content!(
|
||||||
|
tag_name:,
|
||||||
|
body:,
|
||||||
|
created_by_user: user,
|
||||||
|
message: 'init')
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '.content!' do
|
||||||
|
it 'stores normalised body in wiki_pages and wiki_versions' do
|
||||||
|
page = create_page(title: 'commit_normalised_page')
|
||||||
|
|
||||||
|
described_class.content!(
|
||||||
|
page:,
|
||||||
|
body: "a\r\nb\r\n\r\n",
|
||||||
|
created_user: user,
|
||||||
|
message: 'init'
|
||||||
|
)
|
||||||
|
|
||||||
|
page.reload
|
||||||
|
version = page.wiki_versions.order(:version_no).last
|
||||||
|
|
||||||
|
expect(page.body).to eq("a\nb")
|
||||||
|
expect(version.body).to eq("a\nb")
|
||||||
|
expect(page.current_revision.lines_count).to eq(2)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'deduplicates duplicated missing wiki lines before upsert' do
|
||||||
|
page = create_page(title: 'commit_duplicate_line_page')
|
||||||
|
duplicated = 'commit_duplicate_line'
|
||||||
|
|
||||||
|
described_class.content!(
|
||||||
|
page:,
|
||||||
|
body: "#{ duplicated }\n#{ duplicated }",
|
||||||
|
created_user: user,
|
||||||
|
message: 'init'
|
||||||
|
)
|
||||||
|
|
||||||
|
page.reload
|
||||||
|
|
||||||
|
expect(WikiLine.where(body: duplicated).count).to eq(1)
|
||||||
|
expect(page.current_revision.lines_count).to eq(2)
|
||||||
|
expect(page.current_revision.wiki_revision_lines.count).to eq(2)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'raises conflict when base_revision_id is stale' do
|
||||||
|
page = create_page(title: 'commit_conflict_page')
|
||||||
|
|
||||||
|
first = described_class.content!(
|
||||||
|
page:,
|
||||||
|
body: 'first',
|
||||||
|
created_user: user,
|
||||||
|
message: 'first'
|
||||||
|
)
|
||||||
|
|
||||||
|
described_class.content!(
|
||||||
|
page:,
|
||||||
|
body: 'second',
|
||||||
|
created_user: user,
|
||||||
|
message: 'second',
|
||||||
|
base_revision_id: first.id
|
||||||
|
)
|
||||||
|
|
||||||
|
expect {
|
||||||
|
described_class.content!(
|
||||||
|
page:,
|
||||||
|
body: 'third',
|
||||||
|
created_user: user,
|
||||||
|
message: 'third',
|
||||||
|
base_revision_id: first.id
|
||||||
|
)
|
||||||
|
}.to raise_error(Wiki::Commit::Conflict)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'does not record tag version when corresponding tag has no versions' do
|
||||||
|
tag_name = TagName.create!(name: 'commit_linked_tag_without_versions')
|
||||||
|
tag = Tag.create!(tag_name:, category: :general)
|
||||||
|
|
||||||
|
page =
|
||||||
|
described_class.create_content!(
|
||||||
|
tag_name:,
|
||||||
|
body: 'before',
|
||||||
|
created_by_user: user,
|
||||||
|
message: 'init')
|
||||||
|
|
||||||
|
expect(tag.reload.tag_versions.count).to eq(0)
|
||||||
|
|
||||||
|
current_revision_id = page.current_revision.id
|
||||||
|
|
||||||
|
expect {
|
||||||
|
described_class.content!(
|
||||||
|
page:,
|
||||||
|
body: 'after',
|
||||||
|
created_user: user,
|
||||||
|
message: 'edit',
|
||||||
|
base_revision_id: current_revision_id)
|
||||||
|
}.to change(WikiVersion, :count).by(1)
|
||||||
|
|
||||||
|
expect(tag.reload.tag_versions.count).to eq(0)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'does not record tag version when corresponding tag has no versions' do
|
||||||
|
tag_name = TagName.create!(name: 'commit_linked_tag_without_versions')
|
||||||
|
tag = Tag.create!(tag_name:, category: :general)
|
||||||
|
|
||||||
|
page =
|
||||||
|
described_class.create_content!(
|
||||||
|
tag_name:,
|
||||||
|
body: 'before',
|
||||||
|
created_by_user: user,
|
||||||
|
message: 'init')
|
||||||
|
|
||||||
|
current_revision_id = page.current_revision.id
|
||||||
|
|
||||||
|
expect {
|
||||||
|
described_class.content!(
|
||||||
|
page:,
|
||||||
|
body: 'after',
|
||||||
|
created_user: user,
|
||||||
|
message: 'edit',
|
||||||
|
base_revision_id: current_revision_id)
|
||||||
|
}.to change(WikiVersion, :count).by(1)
|
||||||
|
|
||||||
|
expect(tag.reload.tag_versions.count).to eq(0)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '.redirect!' do
|
||||||
|
it 'raises because redirect revisions are deprecated' do
|
||||||
|
page = create_page(title: 'commit_redirect_source')
|
||||||
|
target = create_page(title: 'commit_redirect_target')
|
||||||
|
|
||||||
|
expect {
|
||||||
|
described_class.redirect!(
|
||||||
|
page:,
|
||||||
|
redirect_page: target,
|
||||||
|
created_user: user,
|
||||||
|
message: 'redirect'
|
||||||
|
)
|
||||||
|
}.to raise_error(RuntimeError, '廃止しました.')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe WikiVersionRecorder do
|
||||||
|
let(:user) { create_member_user! }
|
||||||
|
|
||||||
|
def create_page title:, body: 'body'
|
||||||
|
Wiki::Commit.create_content!(
|
||||||
|
tag_name: TagName.create!(name: title),
|
||||||
|
body:,
|
||||||
|
created_by_user: user,
|
||||||
|
message: 'init')
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '.record!' do
|
||||||
|
it 'records title, body, reason, user, and version number' do
|
||||||
|
page = create_page(title: 'wiki_version_recorder_basic', body: 'body')
|
||||||
|
|
||||||
|
expect {
|
||||||
|
described_class.record!(
|
||||||
|
page:,
|
||||||
|
event_type: :update,
|
||||||
|
reason: 'manual reason',
|
||||||
|
created_by_user: user)
|
||||||
|
}
|
||||||
|
.to change { page.reload.wiki_versions.count }.by(1)
|
||||||
|
|
||||||
|
version = page.wiki_versions.order(:version_no).last
|
||||||
|
|
||||||
|
expect(version).to have_attributes(
|
||||||
|
version_no: 2,
|
||||||
|
event_type: 'update',
|
||||||
|
title: 'wiki_version_recorder_basic',
|
||||||
|
body: 'body',
|
||||||
|
reason: 'manual reason',
|
||||||
|
created_by_user_id: user.id
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'does not create duplicated update version for identical snapshot' do
|
||||||
|
page = create_page(title: 'wiki_version_recorder_duplicate', body: 'body')
|
||||||
|
|
||||||
|
described_class.record!(
|
||||||
|
page:,
|
||||||
|
event_type: :update,
|
||||||
|
reason: nil,
|
||||||
|
created_by_user: user)
|
||||||
|
|
||||||
|
before_count = page.reload.wiki_versions.count
|
||||||
|
|
||||||
|
described_class.record!(
|
||||||
|
page:,
|
||||||
|
event_type: :update,
|
||||||
|
reason: nil,
|
||||||
|
created_by_user: user)
|
||||||
|
|
||||||
|
expect(page.reload.wiki_versions.count).to eq(before_count)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'creates update version when title changes' do
|
||||||
|
page = create_page(title: 'wiki_version_recorder_title_before', body: 'body')
|
||||||
|
page.tag_name.update!(name: 'wiki_version_recorder_title_after')
|
||||||
|
|
||||||
|
expect {
|
||||||
|
described_class.record!(
|
||||||
|
page:,
|
||||||
|
event_type: :update,
|
||||||
|
reason: 'rename',
|
||||||
|
created_by_user: user)
|
||||||
|
}
|
||||||
|
.to change { page.reload.wiki_versions.count }.by(1)
|
||||||
|
|
||||||
|
version = page.wiki_versions.order(:version_no).last
|
||||||
|
|
||||||
|
expect(version.title).to eq('wiki_version_recorder_title_after')
|
||||||
|
expect(version.body).to eq('body')
|
||||||
|
expect(version.reason).to eq('rename')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'creates update version when body changes' do
|
||||||
|
page = create_page(title: 'wiki_version_recorder_body', body: 'before')
|
||||||
|
page.update!(body: 'after')
|
||||||
|
|
||||||
|
expect {
|
||||||
|
described_class.record!(
|
||||||
|
page:,
|
||||||
|
event_type: :update,
|
||||||
|
reason: 'body',
|
||||||
|
created_by_user: user)
|
||||||
|
}
|
||||||
|
.to change { page.reload.wiki_versions.count }.by(1)
|
||||||
|
|
||||||
|
version = page.wiki_versions.order(:version_no).last
|
||||||
|
|
||||||
|
expect(version.title).to eq('wiki_version_recorder_body')
|
||||||
|
expect(version.body).to eq('after')
|
||||||
|
expect(version.reason).to eq('body')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
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
|
||||||
@@ -0,0 +1,310 @@
|
|||||||
|
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
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
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
|
||||||
@@ -2,14 +2,12 @@ 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
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
require 'rails_helper'
|
||||||
|
require 'rake'
|
||||||
|
require 'open3'
|
||||||
|
|
||||||
|
RSpec.describe 'nico:export' do
|
||||||
|
let(:task) { Rake::Task['nico:export'] }
|
||||||
|
let(:success_status) { instance_double(Process::Status, success?: true) }
|
||||||
|
let(:failure_status) { instance_double(Process::Status, success?: false) }
|
||||||
|
|
||||||
|
def create_post(url)
|
||||||
|
Post.create!(url:)
|
||||||
|
end
|
||||||
|
|
||||||
|
before(:all) do
|
||||||
|
Rails.application.load_tasks unless Rake::Task.task_defined?('nico:export')
|
||||||
|
end
|
||||||
|
|
||||||
|
before do
|
||||||
|
task.reenable
|
||||||
|
|
||||||
|
allow(ENV).to receive(:fetch).with('MYSQL_USER').and_return('mysql-user')
|
||||||
|
allow(ENV).to receive(:fetch).with('MYSQL_PASS').and_return('mysql-pass')
|
||||||
|
allow(ENV).to receive(:fetch).with('NIZIKA_NICO_PATH').and_return('/srv/nizika-nico')
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'export' do
|
||||||
|
it 'exports nicovideo ids to shared nico DB' do
|
||||||
|
create_post('https://www.nicovideo.jp/watch/sm12345?ref=foo')
|
||||||
|
create_post('https://www.nicovideo.jp/watch/so67890#comments')
|
||||||
|
create_post('https://www.nicovideo.jp/watch/nm24680')
|
||||||
|
create_post('https://example.com/watch/sm99999')
|
||||||
|
|
||||||
|
expect(Open3).to receive(:capture3) do |env, *args, **kwargs|
|
||||||
|
expect(env).to eq(
|
||||||
|
{
|
||||||
|
'MYSQL_USER' => 'mysql-user',
|
||||||
|
'MYSQL_PASS' => 'mysql-pass',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(args.take(3)).to eq(
|
||||||
|
[
|
||||||
|
'python3',
|
||||||
|
'-m',
|
||||||
|
'tracked_videos.put_bulk_upsert',
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(args.drop(3)).to contain_exactly(
|
||||||
|
'sm12345',
|
||||||
|
'so67890',
|
||||||
|
'nm24680',
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(kwargs).to eq(chdir: '/srv/nizika-nico')
|
||||||
|
|
||||||
|
['', '', success_status]
|
||||||
|
end
|
||||||
|
|
||||||
|
task.invoke
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'deduplicates video ids' do
|
||||||
|
create_post('https://www.nicovideo.jp/watch/sm12345')
|
||||||
|
create_post('https://www.nicovideo.jp/watch/sm12345?from=1')
|
||||||
|
|
||||||
|
expect(Open3).to receive(:capture3) do |_env, *args, **_kwargs|
|
||||||
|
expect(args.drop(3)).to eq(['sm12345'])
|
||||||
|
|
||||||
|
['', '', success_status]
|
||||||
|
end
|
||||||
|
|
||||||
|
task.invoke
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'does not call python when there are no nicovideo posts' do
|
||||||
|
create_post('https://example.com/watch/sm12345')
|
||||||
|
|
||||||
|
expect(Open3).not_to receive(:capture3)
|
||||||
|
|
||||||
|
task.invoke
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'raises stderr when python command fails' do
|
||||||
|
create_post('https://www.nicovideo.jp/watch/sm12345')
|
||||||
|
|
||||||
|
allow(Open3).to receive(:capture3).and_return(
|
||||||
|
[
|
||||||
|
'',
|
||||||
|
'bulk upsert failed',
|
||||||
|
failure_status,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
expect {
|
||||||
|
task.invoke
|
||||||
|
}.to raise_error(RuntimeError, 'bulk upsert failed')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -90,4 +90,232 @@ RSpec.describe "nico:sync" do
|
|||||||
expect(active_names).to include("nico:NEW")
|
expect(active_names).to include("nico:NEW")
|
||||||
expect(active_names).not_to include("nico:OLD")
|
expect(active_names).not_to include("nico:OLD")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def snapshot_tags(post)
|
||||||
|
post.snapshot_tag_names.join(' ')
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_post_version_for!(post, version_no: 1, event_type: 'create', created_by_user: nil)
|
||||||
|
PostVersion.create!(
|
||||||
|
post: post,
|
||||||
|
version_no: version_no,
|
||||||
|
event_type: event_type,
|
||||||
|
title: post.title,
|
||||||
|
url: post.url,
|
||||||
|
thumbnail_base: post.thumbnail_base,
|
||||||
|
tags: snapshot_tags(post),
|
||||||
|
parent_post_ids: post.snapshot_parent_post_ids.join(' '),
|
||||||
|
original_created_from: post.original_created_from,
|
||||||
|
original_created_before: post.original_created_before,
|
||||||
|
created_at: Time.current,
|
||||||
|
created_by_user: created_by_user
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
it '新規 post 作成時に version 1 を作る' do
|
||||||
|
Tag.bot
|
||||||
|
Tag.tagme
|
||||||
|
Tag.niconico
|
||||||
|
Tag.video
|
||||||
|
Tag.no_deerjikist
|
||||||
|
|
||||||
|
stub_python([{
|
||||||
|
'code' => 'sm9',
|
||||||
|
'title' => 't',
|
||||||
|
'tags' => ['AAA'],
|
||||||
|
'uploaded_at' => '2026-01-01 12:34:56'
|
||||||
|
}])
|
||||||
|
|
||||||
|
allow(URI).to receive(:open).and_return(StringIO.new('<html></html>'))
|
||||||
|
|
||||||
|
expect {
|
||||||
|
run_rake_task('nico:sync')
|
||||||
|
}.to change(PostVersion, :count).by(1)
|
||||||
|
|
||||||
|
post = Post.find_by!(url: 'https://www.nicovideo.jp/watch/sm9')
|
||||||
|
version = post.post_versions.order(:version_no).last
|
||||||
|
|
||||||
|
expect(version.version_no).to eq(1)
|
||||||
|
expect(version.event_type).to eq('create')
|
||||||
|
expect(version.created_by_user).to be_nil
|
||||||
|
expect(version.tags).to eq(snapshot_tags(post.reload))
|
||||||
|
end
|
||||||
|
|
||||||
|
it '既存 post の内容または tags が変わったとき update version を作る' do
|
||||||
|
post = Post.create!(
|
||||||
|
title: 'old',
|
||||||
|
url: 'https://www.nicovideo.jp/watch/sm9',
|
||||||
|
uploaded_user: nil
|
||||||
|
)
|
||||||
|
|
||||||
|
kept_general = create_tag!('spec_kept', category: 'general')
|
||||||
|
PostTag.create!(post: post, tag: kept_general)
|
||||||
|
create_post_version_for!(post)
|
||||||
|
|
||||||
|
linked = create_tag!('spec_linked', category: 'general')
|
||||||
|
nico = create_tag!('nico:AAA', category: 'nico')
|
||||||
|
link_nico_to_tag!(nico, linked)
|
||||||
|
|
||||||
|
Tag.bot
|
||||||
|
Tag.tagme
|
||||||
|
Tag.no_deerjikist
|
||||||
|
|
||||||
|
stub_python([{
|
||||||
|
'code' => 'sm9',
|
||||||
|
'title' => 't',
|
||||||
|
'tags' => ['AAA'],
|
||||||
|
'uploaded_at' => '2026-01-01 12:34:56'
|
||||||
|
}])
|
||||||
|
|
||||||
|
allow(URI).to receive(:open).and_return(StringIO.new('<html></html>'))
|
||||||
|
|
||||||
|
expect {
|
||||||
|
run_rake_task('nico:sync')
|
||||||
|
}.to change(PostVersion, :count).by(1)
|
||||||
|
|
||||||
|
version = post.reload.post_versions.order(:version_no).last
|
||||||
|
expect(version.version_no).to eq(2)
|
||||||
|
expect(version.event_type).to eq('update')
|
||||||
|
expect(version.created_by_user).to be_nil
|
||||||
|
expect(version.tags).to eq(snapshot_tags(post.reload))
|
||||||
|
end
|
||||||
|
|
||||||
|
it '既存 post に差分が無いときは新しい version を作らない' do
|
||||||
|
nico = create_tag!('nico:AAA', category: 'nico')
|
||||||
|
no_deerjikist = create_tag!('ニジラー情報不詳', category: 'meta')
|
||||||
|
|
||||||
|
post = Post.create!(
|
||||||
|
title: 't',
|
||||||
|
url: 'https://www.nicovideo.jp/watch/sm9',
|
||||||
|
uploaded_user: nil,
|
||||||
|
original_created_from: Time.iso8601('2026-01-01T03:34:00Z'),
|
||||||
|
original_created_before: Time.iso8601('2026-01-01T03:35:00Z')
|
||||||
|
)
|
||||||
|
|
||||||
|
PostTag.create!(post: post, tag: nico)
|
||||||
|
PostTag.create!(post: post, tag: no_deerjikist)
|
||||||
|
create_post_version_for!(post)
|
||||||
|
|
||||||
|
stub_python([{
|
||||||
|
'code' => 'sm9',
|
||||||
|
'title' => 't',
|
||||||
|
'tags' => ['AAA'],
|
||||||
|
'uploaded_at' => '2026-01-01 12:34:56'
|
||||||
|
}])
|
||||||
|
|
||||||
|
allow(URI).to receive(:open).and_return(StringIO.new('<html></html>'))
|
||||||
|
|
||||||
|
expect {
|
||||||
|
run_rake_task('nico:sync')
|
||||||
|
}.not_to change(PostVersion, :count)
|
||||||
|
|
||||||
|
version = post.reload.post_versions.order(:version_no).last
|
||||||
|
expect(version.version_no).to eq(1)
|
||||||
|
expect(version.event_type).to eq('create')
|
||||||
|
expect(version.tags).to eq(snapshot_tags(post.reload))
|
||||||
|
end
|
||||||
|
|
||||||
|
it '新規 nico tag に nico tag version を作る' do
|
||||||
|
Tag.bot
|
||||||
|
Tag.tagme
|
||||||
|
Tag.niconico
|
||||||
|
Tag.video
|
||||||
|
Tag.no_deerjikist
|
||||||
|
|
||||||
|
stub_python([{
|
||||||
|
'code' => 'sm9',
|
||||||
|
'title' => 't',
|
||||||
|
'tags' => ['AAA'],
|
||||||
|
'uploaded_at' => '2026-01-01 12:34:56'
|
||||||
|
}])
|
||||||
|
|
||||||
|
allow(URI).to receive(:open).and_return(StringIO.new('<html></html>'))
|
||||||
|
|
||||||
|
expect {
|
||||||
|
run_rake_task('nico:sync')
|
||||||
|
}.to change(NicoTagVersion, :count).by(1)
|
||||||
|
|
||||||
|
nico_tag = Tag.joins(:tag_name).find_by!(tag_names: { name: 'nico:AAA' })
|
||||||
|
version = nico_tag.nico_tag_versions.order(:version_no).last
|
||||||
|
|
||||||
|
expect(version.version_no).to eq(1)
|
||||||
|
expect(version.event_type).to eq('create')
|
||||||
|
expect(version.name).to eq('nico:AAA')
|
||||||
|
expect(version.created_by_user).to be_nil
|
||||||
|
end
|
||||||
|
|
||||||
|
it '既存 post に version が無い場合は create snapshot を補う' do
|
||||||
|
post = Post.create!(
|
||||||
|
title: 'old',
|
||||||
|
url: 'https://www.nicovideo.jp/watch/sm9',
|
||||||
|
uploaded_user: nil
|
||||||
|
)
|
||||||
|
|
||||||
|
kept_general = create_tag!('spec_kept_without_version', category: 'general')
|
||||||
|
PostTag.create!(post: post, tag: kept_general)
|
||||||
|
|
||||||
|
Tag.bot
|
||||||
|
Tag.tagme
|
||||||
|
Tag.no_deerjikist
|
||||||
|
|
||||||
|
stub_python([{
|
||||||
|
'code' => 'sm9',
|
||||||
|
'title' => 'changed title',
|
||||||
|
'tags' => ['AAA'],
|
||||||
|
'uploaded_at' => '2026-01-01 12:34:56'
|
||||||
|
}])
|
||||||
|
|
||||||
|
allow(URI).to receive(:open).and_return(StringIO.new('<html></html>'))
|
||||||
|
|
||||||
|
expect {
|
||||||
|
run_rake_task('nico:sync')
|
||||||
|
}.to change { post.reload.post_versions.count }.by(1)
|
||||||
|
|
||||||
|
versions = post.reload.post_versions.order(:version_no)
|
||||||
|
|
||||||
|
expect(versions.map(&:event_type)).to eq(['create'])
|
||||||
|
expect(versions.first.title).to eq('changed title')
|
||||||
|
expect(versions.first.tags).to eq(snapshot_tags(post.reload))
|
||||||
|
end
|
||||||
|
|
||||||
|
it '既存 version がある post には update version を作る' do
|
||||||
|
post = Post.create!(
|
||||||
|
title: 'old',
|
||||||
|
url: 'https://www.nicovideo.jp/watch/sm9',
|
||||||
|
uploaded_user: nil
|
||||||
|
)
|
||||||
|
|
||||||
|
kept_general = create_tag!('spec_kept_with_version', category: 'general')
|
||||||
|
PostTag.create!(post: post, tag: kept_general)
|
||||||
|
|
||||||
|
PostVersionRecorder.record!(
|
||||||
|
post: post,
|
||||||
|
event_type: :create,
|
||||||
|
created_by_user: nil
|
||||||
|
)
|
||||||
|
|
||||||
|
Tag.bot
|
||||||
|
Tag.tagme
|
||||||
|
Tag.no_deerjikist
|
||||||
|
|
||||||
|
stub_python([{
|
||||||
|
'code' => 'sm9',
|
||||||
|
'title' => 'changed title',
|
||||||
|
'tags' => ['AAA'],
|
||||||
|
'uploaded_at' => '2026-01-01 12:34:56'
|
||||||
|
}])
|
||||||
|
|
||||||
|
allow(URI).to receive(:open).and_return(StringIO.new('<html></html>'))
|
||||||
|
|
||||||
|
expect {
|
||||||
|
run_rake_task('nico:sync')
|
||||||
|
}.to change { post.reload.post_versions.count }.by(1)
|
||||||
|
|
||||||
|
versions = post.reload.post_versions.order(:version_no)
|
||||||
|
|
||||||
|
expect(versions.map(&:event_type)).to eq(['create', 'update'])
|
||||||
|
expect(versions.first.title).to eq('old')
|
||||||
|
expect(versions.second.title).to eq('changed title')
|
||||||
|
expect(versions.second.tags).to eq(snapshot_tags(post.reload))
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
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
|
||||||
@@ -0,0 +1,646 @@
|
|||||||
|
# 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.
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# 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 test
|
||||||
|
```
|
||||||
|
|
||||||
|
### Full verification
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd backend && bundle exec rspec
|
||||||
|
cd ../frontend && npm run build && npm run lint
|
||||||
|
```
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
# 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.
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# Release checklist
|
||||||
|
|
||||||
|
- [ ] Backend specs pass
|
||||||
|
- [ ] Frontend build passes
|
||||||
|
- [ ] No pending migrations
|
||||||
|
- [ ] Preview API SSRF checked
|
||||||
|
- [ ] BAN behavior checked
|
||||||
|
- [ ] CSP checked
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# Roadmap
|
||||||
|
|
||||||
|
## Public announcement readiness
|
||||||
|
|
||||||
|
- Harden preview API
|
||||||
|
- Tighten material creation permission
|
||||||
|
- Add admin MVP
|
||||||
|
- Improve frontend tests
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
- 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.
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
## 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.
|
||||||
Generated
+925
-181
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user