コミットを比較
6
コミット
| 作成者 | SHA1 | 日付 | |
|---|---|---|---|
|
|
d6fc57a00e | ||
|
|
dda3d87073 | ||
|
|
e13a52db9a | ||
|
|
f05d8a4b3a | ||
|
|
dbae864f24 | ||
|
|
8d00a6545e |
@@ -1,35 +0,0 @@
|
||||
## 背景
|
||||
|
||||
なぜ必要か。
|
||||
|
||||
## 対象範囲
|
||||
|
||||
- backend:
|
||||
- frontend:
|
||||
- docs:
|
||||
- migration:
|
||||
|
||||
## やること
|
||||
|
||||
- [ ]
|
||||
|
||||
## 受け入れ条件
|
||||
|
||||
- [ ]
|
||||
|
||||
## 実行すべき確認
|
||||
|
||||
- [ ] `cd backend && bundle exec rspec`
|
||||
- [ ] `cd frontend && npm run build`
|
||||
- [ ] `cd frontend && npm run lint`
|
||||
|
||||
## 禁止事項
|
||||
|
||||
- unrelated refactor はしない
|
||||
- 既存 API response shape を壊さない
|
||||
- 認証・認可・BAN を弱めない
|
||||
|
||||
## Codex への指示
|
||||
|
||||
この issue を読んで実装してください。
|
||||
不明点があれば、実装前に調査結果と選択肢を提示してください。
|
||||
+1
-1
@@ -424,7 +424,7 @@ races according to the target constraint's business meaning.
|
||||
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
|
||||
user, `:not_found` for missing records, and `:unprocessable_content` for
|
||||
validation failures.
|
||||
- For diagnostic or internal helper JSON, prefer a deliberately light response
|
||||
shape over full representation classes when callers only need identifiers,
|
||||
|
||||
@@ -71,3 +71,5 @@ gem "rspec-rails", "~> 8.0", :groups => [:development, :test]
|
||||
gem 'aws-sdk-s3', require: false
|
||||
|
||||
gem 'rails-i18n', '~> 8.0.0'
|
||||
|
||||
gem "json", "~> 2.19.9"
|
||||
|
||||
+142
-134
@@ -1,29 +1,29 @@
|
||||
GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
actioncable (8.0.2)
|
||||
actionpack (= 8.0.2)
|
||||
activesupport (= 8.0.2)
|
||||
actioncable (8.0.5.1)
|
||||
actionpack (= 8.0.5.1)
|
||||
activesupport (= 8.0.5.1)
|
||||
nio4r (~> 2.0)
|
||||
websocket-driver (>= 0.6.1)
|
||||
zeitwerk (~> 2.6)
|
||||
actionmailbox (8.0.2)
|
||||
actionpack (= 8.0.2)
|
||||
activejob (= 8.0.2)
|
||||
activerecord (= 8.0.2)
|
||||
activestorage (= 8.0.2)
|
||||
activesupport (= 8.0.2)
|
||||
actionmailbox (8.0.5.1)
|
||||
actionpack (= 8.0.5.1)
|
||||
activejob (= 8.0.5.1)
|
||||
activerecord (= 8.0.5.1)
|
||||
activestorage (= 8.0.5.1)
|
||||
activesupport (= 8.0.5.1)
|
||||
mail (>= 2.8.0)
|
||||
actionmailer (8.0.2)
|
||||
actionpack (= 8.0.2)
|
||||
actionview (= 8.0.2)
|
||||
activejob (= 8.0.2)
|
||||
activesupport (= 8.0.2)
|
||||
actionmailer (8.0.5.1)
|
||||
actionpack (= 8.0.5.1)
|
||||
actionview (= 8.0.5.1)
|
||||
activejob (= 8.0.5.1)
|
||||
activesupport (= 8.0.5.1)
|
||||
mail (>= 2.8.0)
|
||||
rails-dom-testing (~> 2.2)
|
||||
actionpack (8.0.2)
|
||||
actionview (= 8.0.2)
|
||||
activesupport (= 8.0.2)
|
||||
actionpack (8.0.5.1)
|
||||
actionview (= 8.0.5.1)
|
||||
activesupport (= 8.0.5.1)
|
||||
nokogiri (>= 1.8.5)
|
||||
rack (>= 2.2.4)
|
||||
rack-session (>= 1.0.1)
|
||||
@@ -31,35 +31,35 @@ GEM
|
||||
rails-dom-testing (~> 2.2)
|
||||
rails-html-sanitizer (~> 1.6)
|
||||
useragent (~> 0.16)
|
||||
actiontext (8.0.2)
|
||||
actionpack (= 8.0.2)
|
||||
activerecord (= 8.0.2)
|
||||
activestorage (= 8.0.2)
|
||||
activesupport (= 8.0.2)
|
||||
actiontext (8.0.5.1)
|
||||
actionpack (= 8.0.5.1)
|
||||
activerecord (= 8.0.5.1)
|
||||
activestorage (= 8.0.5.1)
|
||||
activesupport (= 8.0.5.1)
|
||||
globalid (>= 0.6.0)
|
||||
nokogiri (>= 1.8.5)
|
||||
actionview (8.0.2)
|
||||
activesupport (= 8.0.2)
|
||||
actionview (8.0.5.1)
|
||||
activesupport (= 8.0.5.1)
|
||||
builder (~> 3.1)
|
||||
erubi (~> 1.11)
|
||||
rails-dom-testing (~> 2.2)
|
||||
rails-html-sanitizer (~> 1.6)
|
||||
activejob (8.0.2)
|
||||
activesupport (= 8.0.2)
|
||||
activejob (8.0.5.1)
|
||||
activesupport (= 8.0.5.1)
|
||||
globalid (>= 0.3.6)
|
||||
activemodel (8.0.2)
|
||||
activesupport (= 8.0.2)
|
||||
activerecord (8.0.2)
|
||||
activemodel (= 8.0.2)
|
||||
activesupport (= 8.0.2)
|
||||
activemodel (8.0.5.1)
|
||||
activesupport (= 8.0.5.1)
|
||||
activerecord (8.0.5.1)
|
||||
activemodel (= 8.0.5.1)
|
||||
activesupport (= 8.0.5.1)
|
||||
timeout (>= 0.4.0)
|
||||
activestorage (8.0.2)
|
||||
actionpack (= 8.0.2)
|
||||
activejob (= 8.0.2)
|
||||
activerecord (= 8.0.2)
|
||||
activesupport (= 8.0.2)
|
||||
activestorage (8.0.5.1)
|
||||
actionpack (= 8.0.5.1)
|
||||
activejob (= 8.0.5.1)
|
||||
activerecord (= 8.0.5.1)
|
||||
activesupport (= 8.0.5.1)
|
||||
marcel (~> 1.0)
|
||||
activesupport (8.0.2)
|
||||
activesupport (8.0.5.1)
|
||||
base64
|
||||
benchmark (>= 0.3)
|
||||
bigdecimal
|
||||
@@ -92,22 +92,22 @@ GEM
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sigv4 (1.12.1)
|
||||
aws-eventstream (~> 1, >= 1.0.2)
|
||||
base64 (0.2.0)
|
||||
base64 (0.3.0)
|
||||
bcrypt_pbkdf (1.1.1)
|
||||
bcrypt_pbkdf (1.1.1-arm64-darwin)
|
||||
bcrypt_pbkdf (1.1.1-x86_64-darwin)
|
||||
benchmark (0.4.0)
|
||||
bigdecimal (3.1.9)
|
||||
benchmark (0.5.0)
|
||||
bigdecimal (4.1.3)
|
||||
bootsnap (1.18.6)
|
||||
msgpack (~> 1.2)
|
||||
brakeman (7.0.2)
|
||||
racc
|
||||
builder (3.3.0)
|
||||
chronic (0.10.2)
|
||||
concurrent-ruby (1.3.5)
|
||||
connection_pool (2.5.3)
|
||||
crass (1.0.6)
|
||||
date (3.4.1)
|
||||
concurrent-ruby (1.3.8)
|
||||
connection_pool (3.0.2)
|
||||
crass (1.0.7)
|
||||
date (3.6.0)
|
||||
diff-lcs (1.6.2)
|
||||
discard (1.4.0)
|
||||
activerecord (>= 4.2, < 9.0)
|
||||
@@ -115,8 +115,9 @@ GEM
|
||||
dotenv-rails (3.1.8)
|
||||
dotenv (= 3.1.8)
|
||||
railties (>= 6.1)
|
||||
drb (2.2.1)
|
||||
drb (2.2.3)
|
||||
ed25519 (1.4.0)
|
||||
erb (6.0.7)
|
||||
erubi (1.13.1)
|
||||
factory_bot (6.5.6)
|
||||
activesupport (>= 6.1.0)
|
||||
@@ -134,7 +135,7 @@ GEM
|
||||
gemojione (4.3.3)
|
||||
json
|
||||
github-markup (4.0.2)
|
||||
globalid (1.2.1)
|
||||
globalid (1.4.0)
|
||||
activesupport (>= 6.1)
|
||||
gollum (6.1.0)
|
||||
gemojione (~> 4.1)
|
||||
@@ -166,19 +167,20 @@ GEM
|
||||
gollum-rugged_adapter (3.0)
|
||||
mime-types (~> 3.4)
|
||||
rugged (~> 1.5)
|
||||
i18n (1.14.7)
|
||||
i18n (1.15.2)
|
||||
concurrent-ruby (~> 1.0)
|
||||
image_processing (1.14.0)
|
||||
mini_magick (>= 4.9.5, < 6)
|
||||
ruby-vips (>= 2.0.17, < 3)
|
||||
io-console (0.8.0)
|
||||
irb (1.15.2)
|
||||
io-console (0.9.4)
|
||||
irb (1.18.0)
|
||||
pp (>= 0.6.0)
|
||||
prism (>= 1.3.0)
|
||||
rdoc (>= 4.0.0)
|
||||
reline (>= 0.4.2)
|
||||
jmespath (1.6.2)
|
||||
json (2.12.0)
|
||||
jwt (2.10.1)
|
||||
json (2.19.9)
|
||||
jwt (3.3.0)
|
||||
base64
|
||||
kamal (2.6.1)
|
||||
activesupport (>= 7.0)
|
||||
@@ -198,15 +200,16 @@ GEM
|
||||
language_server-protocol (3.17.0.5)
|
||||
lint_roller (1.1.0)
|
||||
logger (1.7.0)
|
||||
loofah (2.24.1)
|
||||
loofah (2.25.2)
|
||||
crass (~> 1.0.2)
|
||||
nokogiri (>= 1.12.0)
|
||||
mail (2.8.1)
|
||||
mail (2.9.1)
|
||||
logger
|
||||
mini_mime (>= 0.1.1)
|
||||
net-imap
|
||||
net-pop
|
||||
net-smtp
|
||||
marcel (1.0.4)
|
||||
marcel (1.2.1)
|
||||
mime-types (3.7.0)
|
||||
logger
|
||||
mime-types-data (~> 3.2025, >= 3.2025.0507)
|
||||
@@ -215,45 +218,46 @@ GEM
|
||||
benchmark
|
||||
logger
|
||||
mini_mime (1.1.5)
|
||||
minitest (5.25.5)
|
||||
msgpack (1.8.0)
|
||||
multi_json (1.15.0)
|
||||
minitest (6.0.6)
|
||||
drb (~> 2.0)
|
||||
prism (~> 1.5)
|
||||
msgpack (1.8.5)
|
||||
multi_json (1.21.2)
|
||||
mustache (1.1.1)
|
||||
mustache-sinatra (2.0.0)
|
||||
mustache (~> 1.0)
|
||||
mustermann (3.0.3)
|
||||
ruby2_keywords (~> 0.0.1)
|
||||
mustermann (3.1.1)
|
||||
mysql2 (0.5.6)
|
||||
net-imap (0.5.8)
|
||||
net-imap (0.6.8)
|
||||
date
|
||||
net-protocol
|
||||
net-pop (0.1.2)
|
||||
net-protocol
|
||||
net-protocol (0.2.2)
|
||||
net-protocol (0.4.0)
|
||||
timeout
|
||||
net-scp (4.1.0)
|
||||
net-ssh (>= 2.6.5, < 8.0.0)
|
||||
net-sftp (4.0.0)
|
||||
net-ssh (>= 5.0.0, < 8.0.0)
|
||||
net-smtp (0.5.1)
|
||||
net-smtp (0.5.2)
|
||||
net-protocol
|
||||
net-ssh (7.3.0)
|
||||
nio4r (2.7.4)
|
||||
nokogiri (1.18.8-aarch64-linux-gnu)
|
||||
nio4r (2.7.5)
|
||||
nokogiri (1.19.4-aarch64-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.8-aarch64-linux-musl)
|
||||
nokogiri (1.19.4-aarch64-linux-musl)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.8-arm-linux-gnu)
|
||||
nokogiri (1.19.4-arm-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.8-arm-linux-musl)
|
||||
nokogiri (1.19.4-arm-linux-musl)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.8-arm64-darwin)
|
||||
nokogiri (1.19.4-arm64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.8-x86_64-darwin)
|
||||
nokogiri (1.19.4-x86_64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.8-x86_64-linux-gnu)
|
||||
nokogiri (1.19.4-x86_64-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.8-x86_64-linux-musl)
|
||||
nokogiri (1.19.4-x86_64-linux-musl)
|
||||
racc (~> 1.4)
|
||||
octicons (19.15.2)
|
||||
ostruct (0.6.1)
|
||||
@@ -261,70 +265,73 @@ GEM
|
||||
parser (3.3.8.0)
|
||||
ast (~> 2.4.1)
|
||||
racc
|
||||
pp (0.6.2)
|
||||
pp (0.6.4)
|
||||
prettyprint
|
||||
prettyprint (0.2.0)
|
||||
prism (1.4.0)
|
||||
psych (5.2.6)
|
||||
prettyprint (0.2.1)
|
||||
prism (1.9.0)
|
||||
psych (5.5.0)
|
||||
date
|
||||
stringio
|
||||
puma (6.6.0)
|
||||
nio4r (~> 2.0)
|
||||
racc (1.8.1)
|
||||
rack (3.1.14)
|
||||
rack (3.2.7)
|
||||
rack-cors (2.0.2)
|
||||
rack (>= 2.0.0)
|
||||
rack-protection (4.1.1)
|
||||
rack-protection (4.2.1)
|
||||
base64 (>= 0.1.0)
|
||||
logger (>= 1.6.0)
|
||||
rack (>= 3.0.0, < 4)
|
||||
rack-session (2.1.1)
|
||||
rack-session (2.1.2)
|
||||
base64 (>= 0.1.0)
|
||||
rack (>= 3.0.0)
|
||||
rack-test (2.2.0)
|
||||
rack-test (2.3.0)
|
||||
rack (>= 1.3)
|
||||
rackup (2.2.1)
|
||||
rackup (2.3.1)
|
||||
rack (>= 3)
|
||||
rails (8.0.2)
|
||||
actioncable (= 8.0.2)
|
||||
actionmailbox (= 8.0.2)
|
||||
actionmailer (= 8.0.2)
|
||||
actionpack (= 8.0.2)
|
||||
actiontext (= 8.0.2)
|
||||
actionview (= 8.0.2)
|
||||
activejob (= 8.0.2)
|
||||
activemodel (= 8.0.2)
|
||||
activerecord (= 8.0.2)
|
||||
activestorage (= 8.0.2)
|
||||
activesupport (= 8.0.2)
|
||||
rails (8.0.5.1)
|
||||
actioncable (= 8.0.5.1)
|
||||
actionmailbox (= 8.0.5.1)
|
||||
actionmailer (= 8.0.5.1)
|
||||
actionpack (= 8.0.5.1)
|
||||
actiontext (= 8.0.5.1)
|
||||
actionview (= 8.0.5.1)
|
||||
activejob (= 8.0.5.1)
|
||||
activemodel (= 8.0.5.1)
|
||||
activerecord (= 8.0.5.1)
|
||||
activestorage (= 8.0.5.1)
|
||||
activesupport (= 8.0.5.1)
|
||||
bundler (>= 1.15.0)
|
||||
railties (= 8.0.2)
|
||||
rails-dom-testing (2.2.0)
|
||||
railties (= 8.0.5.1)
|
||||
rails-dom-testing (2.3.0)
|
||||
activesupport (>= 5.0.0)
|
||||
minitest
|
||||
nokogiri (>= 1.6)
|
||||
rails-html-sanitizer (1.6.2)
|
||||
loofah (~> 2.21)
|
||||
rails-html-sanitizer (1.7.1)
|
||||
loofah (~> 2.25, >= 2.25.2)
|
||||
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
|
||||
rails-i18n (8.0.2)
|
||||
i18n (>= 0.7, < 2)
|
||||
railties (>= 8.0.0, < 9)
|
||||
railties (8.0.2)
|
||||
actionpack (= 8.0.2)
|
||||
activesupport (= 8.0.2)
|
||||
railties (8.0.5.1)
|
||||
actionpack (= 8.0.5.1)
|
||||
activesupport (= 8.0.5.1)
|
||||
irb (~> 1.13)
|
||||
rackup (>= 1.0.0)
|
||||
rake (>= 12.2)
|
||||
thor (~> 1.0, >= 1.2.2)
|
||||
tsort (>= 0.2)
|
||||
zeitwerk (~> 2.6)
|
||||
rainbow (3.1.1)
|
||||
rake (13.2.1)
|
||||
rdoc (6.13.1)
|
||||
rake (13.4.2)
|
||||
rdoc (6.17.0)
|
||||
erb
|
||||
psych (>= 4.0.0)
|
||||
regexp_parser (2.10.0)
|
||||
reline (0.6.1)
|
||||
tsort
|
||||
regexp_parser (2.13.1)
|
||||
reline (0.7.0)
|
||||
io-console (~> 0.5)
|
||||
rexml (3.4.1)
|
||||
rexml (3.4.4)
|
||||
rouge (3.30.0)
|
||||
rspec-core (3.13.6)
|
||||
rspec-support (~> 3.13.0)
|
||||
@@ -345,20 +352,20 @@ GEM
|
||||
rspec-support (3.13.6)
|
||||
rss (0.3.1)
|
||||
rexml
|
||||
rubocop (1.75.6)
|
||||
json (~> 2.3)
|
||||
rubocop (1.92.0)
|
||||
json (>= 2.3)
|
||||
language_server-protocol (~> 3.17.0.2)
|
||||
lint_roller (~> 1.1.0)
|
||||
parallel (~> 1.10)
|
||||
parallel (>= 1.10)
|
||||
parser (>= 3.3.0.2)
|
||||
rainbow (>= 2.2.2, < 4.0)
|
||||
regexp_parser (>= 2.9.3, < 3.0)
|
||||
rubocop-ast (>= 1.44.0, < 2.0)
|
||||
regexp_parser (>= 2.11.3, < 3.0)
|
||||
rubocop-ast (>= 1.49.0, < 2.0)
|
||||
ruby-progressbar (~> 1.7)
|
||||
unicode-display_width (>= 2.4.0, < 4.0)
|
||||
rubocop-ast (1.44.1)
|
||||
rubocop-ast (1.50.0)
|
||||
parser (>= 3.3.7.2)
|
||||
prism (~> 1.4)
|
||||
prism (~> 1.7)
|
||||
rubocop-performance (1.25.0)
|
||||
lint_roller (~> 1.1)
|
||||
rubocop (>= 1.75.0, < 2.0)
|
||||
@@ -377,24 +384,23 @@ GEM
|
||||
ruby-vips (2.2.3)
|
||||
ffi (~> 1.12)
|
||||
logger
|
||||
ruby2_keywords (0.0.5)
|
||||
rugged (1.9.0)
|
||||
securerandom (0.4.1)
|
||||
sinatra (4.1.1)
|
||||
sinatra (4.2.1)
|
||||
logger (>= 1.6.0)
|
||||
mustermann (~> 3.0)
|
||||
rack (>= 3.0.0, < 4)
|
||||
rack-protection (= 4.1.1)
|
||||
rack-protection (= 4.2.1)
|
||||
rack-session (>= 2.0.0, < 3)
|
||||
tilt (~> 2.0)
|
||||
sinatra-contrib (4.1.1)
|
||||
sinatra-contrib (4.2.1)
|
||||
multi_json (>= 0.0.2)
|
||||
mustermann (~> 3.0)
|
||||
rack-protection (= 4.1.1)
|
||||
sinatra (= 4.1.1)
|
||||
rack-protection (= 4.2.1)
|
||||
sinatra (= 4.2.1)
|
||||
tilt (~> 2.0)
|
||||
sprockets (4.2.2)
|
||||
concurrent-ruby (~> 1.0)
|
||||
sprockets (4.4.1)
|
||||
concurrent-ruby (~> 1.1)
|
||||
logger
|
||||
rack (>= 2.2.4, < 4)
|
||||
sprockets-helpers (1.4.0)
|
||||
@@ -403,14 +409,14 @@ GEM
|
||||
actionpack (>= 6.1)
|
||||
activesupport (>= 6.1)
|
||||
sprockets (>= 3.0.0)
|
||||
sqlite3 (2.6.0-aarch64-linux-gnu)
|
||||
sqlite3 (2.6.0-aarch64-linux-musl)
|
||||
sqlite3 (2.6.0-arm-linux-gnu)
|
||||
sqlite3 (2.6.0-arm-linux-musl)
|
||||
sqlite3 (2.6.0-arm64-darwin)
|
||||
sqlite3 (2.6.0-x86_64-darwin)
|
||||
sqlite3 (2.6.0-x86_64-linux-gnu)
|
||||
sqlite3 (2.6.0-x86_64-linux-musl)
|
||||
sqlite3 (2.10.1-aarch64-linux-gnu)
|
||||
sqlite3 (2.10.1-aarch64-linux-musl)
|
||||
sqlite3 (2.10.1-arm-linux-gnu)
|
||||
sqlite3 (2.10.1-arm-linux-musl)
|
||||
sqlite3 (2.10.1-arm64-darwin)
|
||||
sqlite3 (2.10.1-x86_64-darwin)
|
||||
sqlite3 (2.10.1-x86_64-linux-gnu)
|
||||
sqlite3 (2.10.1-x86_64-linux-musl)
|
||||
sshkit (1.24.0)
|
||||
base64
|
||||
logger
|
||||
@@ -418,18 +424,19 @@ GEM
|
||||
net-sftp (>= 2.1.2)
|
||||
net-ssh (>= 2.8.0)
|
||||
ostruct
|
||||
stringio (3.1.7)
|
||||
stringio (3.2.0)
|
||||
therubyrhino (2.1.2)
|
||||
therubyrhino_jar (>= 1.7.4, < 1.7.9)
|
||||
therubyrhino_jar (1.7.8)
|
||||
thor (1.3.2)
|
||||
thor (1.5.0)
|
||||
thruster (0.1.13)
|
||||
thruster (0.1.13-aarch64-linux)
|
||||
thruster (0.1.13-arm64-darwin)
|
||||
thruster (0.1.13-x86_64-darwin)
|
||||
thruster (0.1.13-x86_64-linux)
|
||||
tilt (2.6.0)
|
||||
timeout (0.4.3)
|
||||
tilt (2.9.0)
|
||||
timeout (0.6.1)
|
||||
tsort (0.2.0)
|
||||
twitter-text (1.14.7)
|
||||
unf (~> 0.1.0)
|
||||
tzinfo (2.0.6)
|
||||
@@ -440,16 +447,16 @@ GEM
|
||||
unicode-display_width (3.1.4)
|
||||
unicode-emoji (~> 4.0, >= 4.0.4)
|
||||
unicode-emoji (4.0.4)
|
||||
uri (1.0.3)
|
||||
uri (1.1.2)
|
||||
useragent (0.16.11)
|
||||
webrick (1.9.1)
|
||||
websocket-driver (0.7.7)
|
||||
websocket-driver (0.8.2)
|
||||
base64
|
||||
websocket-extensions (>= 0.1.0)
|
||||
websocket-extensions (0.1.5)
|
||||
whenever (1.0.0)
|
||||
chronic (>= 0.6.3)
|
||||
zeitwerk (2.7.2)
|
||||
zeitwerk (2.8.3)
|
||||
|
||||
PLATFORMS
|
||||
aarch64-linux
|
||||
@@ -473,6 +480,7 @@ DEPENDENCIES
|
||||
factory_bot_rails
|
||||
gollum
|
||||
image_processing (~> 1.14)
|
||||
json (~> 2.19.9)
|
||||
jwt
|
||||
kamal
|
||||
mysql2 (~> 0.5.6)
|
||||
|
||||
@@ -95,7 +95,7 @@ class ApplicationController < ActionController::API
|
||||
render_validation_error base: ['すでに存在してゐます.']
|
||||
end
|
||||
|
||||
def render_validation_error record = nil, fields: { }, base: [], status: :unprocessable_entity
|
||||
def render_validation_error record = nil, fields: { }, base: [], status: :unprocessable_content
|
||||
errors = { }
|
||||
|
||||
if record
|
||||
|
||||
@@ -18,7 +18,7 @@ class CountsExportsController < ApplicationController
|
||||
render json: { error: 'private_condition' }, status: :forbidden
|
||||
rescue CountsTableQuery::TooManyColumns
|
||||
render json: { error: 'too_many_columns', max_columns: CountsTableQuery::MAX_COLUMNS },
|
||||
status: :unprocessable_entity
|
||||
status: :unprocessable_content
|
||||
rescue ActiveRecord::StatementTimeout
|
||||
render json: { error: 'counts_query_timeout' }, status: :service_unavailable
|
||||
rescue ArgumentError, TypeError, KeyError, JSON::ParserError
|
||||
@@ -63,7 +63,7 @@ class CountsExportsController < ApplicationController
|
||||
render json: { error: 'private_condition' }, status: :forbidden
|
||||
rescue CountsTableQuery::TooManyColumns
|
||||
render json: { error: 'too_many_columns', max_columns: CountsTableQuery::MAX_COLUMNS },
|
||||
status: :unprocessable_entity
|
||||
status: :unprocessable_content
|
||||
rescue ActiveRecord::StatementTimeout
|
||||
render json: { error: 'counts_query_timeout' }, status: :service_unavailable
|
||||
rescue ArgumentError, TypeError, KeyError, JSON::ParserError
|
||||
@@ -107,7 +107,7 @@ class CountsExportsController < ApplicationController
|
||||
render json: { error: 'private_condition' }, status: :forbidden
|
||||
rescue CountsTableQuery::TooManyColumns
|
||||
render json: { error: 'too_many_columns', max_columns: CountsTableQuery::MAX_COLUMNS },
|
||||
status: :unprocessable_entity
|
||||
status: :unprocessable_content
|
||||
rescue ActiveRecord::StatementTimeout
|
||||
render json: { error: 'counts_query_timeout' }, status: :service_unavailable
|
||||
rescue ArgumentError, TypeError, KeyError, JSON::ParserError
|
||||
|
||||
@@ -24,7 +24,7 @@ class CountsTablesController < ApplicationController
|
||||
render json: { error: 'private_condition' }, status: :forbidden
|
||||
rescue CountsTableQuery::TooManyColumns
|
||||
render json: { error: 'too_many_columns', max_columns: CountsTableQuery::MAX_COLUMNS },
|
||||
status: :unprocessable_entity
|
||||
status: :unprocessable_content
|
||||
rescue CountsPivotTotals::TimedOut
|
||||
Rails.logger.warn('counts_query_timeout: pivot_totals')
|
||||
render json: { error: 'counts_query_timeout', stage: 'totals',
|
||||
|
||||
@@ -15,7 +15,7 @@ class GekanatorGamesController < ApplicationController
|
||||
answers:)
|
||||
|
||||
if game.invalid?
|
||||
render json: { errors: game.errors.full_messages }, status: :unprocessable_entity
|
||||
render json: { errors: game.errors.full_messages }, status: :unprocessable_content
|
||||
return
|
||||
end
|
||||
|
||||
@@ -31,7 +31,7 @@ class GekanatorGamesController < ApplicationController
|
||||
learned_example_count:
|
||||
}, status: :created
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
render json: { errors: e.record.errors.full_messages }, status: :unprocessable_entity
|
||||
render json: { errors: e.record.errors.full_messages }, status: :unprocessable_content
|
||||
end
|
||||
|
||||
def extra_questions
|
||||
|
||||
@@ -536,7 +536,7 @@ class MaterialsController < ApplicationController
|
||||
errors: { },
|
||||
base_errors: ['ZIP export に必要な素材ファイルが欠損しています.'],
|
||||
missing_files: },
|
||||
status: :unprocessable_entity
|
||||
status: :unprocessable_content
|
||||
end
|
||||
|
||||
def build_uploaded_material_blob! file, file_sha256
|
||||
|
||||
@@ -14,8 +14,9 @@ class PostVersionsController < ApplicationController
|
||||
|
||||
offset = (page - 1) * limit
|
||||
|
||||
q = HistoryAccess.visible(version_scope)
|
||||
q = HistoryAccess.visible(PostVersion.all)
|
||||
q = q.where('post_versions.post_id = ?', post_id) if post_id
|
||||
q = q.with_previous_version if tag_id || external_tag_id
|
||||
if external_tag_id || (tag_id && !(Tag.exists?(id: tag_id)))
|
||||
q = q.where('JSON_CONTAINS(post_versions.tags_json,' +
|
||||
"JSON_OBJECT('external_tag_id', #{ external_tag_id || tag_id })) " +
|
||||
@@ -28,9 +29,12 @@ class PostVersionsController < ApplicationController
|
||||
|
||||
count = q.except(:select, :order, :limit, :offset).count
|
||||
|
||||
versions = q.order(Arel.sql('post_versions.created_at DESC, post_versions.id DESC'))
|
||||
ids = q.order(Arel.sql('post_versions.created_at DESC, post_versions.id DESC'))
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
.pluck('post_versions.id')
|
||||
versions = version_scope.merge(q).where(id: ids)
|
||||
.order(Arel.sql('post_versions.created_at DESC, post_versions.id DESC'))
|
||||
|
||||
render json: { versions: serialise_versions(versions, locale.language_code), count:, population_count: HistoryAccess.population_count(PostVersion) }
|
||||
end
|
||||
@@ -96,18 +100,9 @@ class PostVersionsController < ApplicationController
|
||||
|
||||
def external_tag_names_for snapshots
|
||||
ids = snapshots.flatten.filter_map { _1['external_tag_id'] }.uniq
|
||||
names = ExternalTag.where(id: ids).pluck(:id, :platform, :name).to_h { |id, platform, name|
|
||||
ExternalTag.where(id: ids).pluck(:id, :platform, :name).to_h { |id, platform, name|
|
||||
[id, "#{ platform }:#{ name }"]
|
||||
}
|
||||
missing_ids = ids - names.keys
|
||||
|
||||
HistoryAccess.visible(NicoTagVersion.all)
|
||||
.where(tag_id: missing_ids)
|
||||
.order(:tag_id, version_no: :desc)
|
||||
.pluck(:tag_id, :name)
|
||||
.each { |id, name| names[id] ||= name }
|
||||
|
||||
names
|
||||
end
|
||||
|
||||
def snapshot_tag_literals snapshots, tag_names, external_tag_names
|
||||
|
||||
@@ -37,20 +37,10 @@ class PostsController < ApplicationController
|
||||
|
||||
offset = (page - 1) * limit
|
||||
|
||||
pt_max_sql =
|
||||
HistoryAccess.visible(PostVersion.all)
|
||||
.select('post_id, MAX(created_at) AS max_updated_at')
|
||||
.group('post_id')
|
||||
.to_sql
|
||||
|
||||
updated_at_all_sql =
|
||||
'GREATEST(posts.updated_at,' +
|
||||
'COALESCE(pt_max.max_updated_at, posts.updated_at))'
|
||||
needs_history_join = order[0] == 'updated_at' || updated_between.any?(&:present?)
|
||||
|
||||
q =
|
||||
filtered_posts(locale)
|
||||
.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"))
|
||||
.preload(:external_tags, :uploaded_user, :parents, :children,
|
||||
post_tags: [:sections, { tag: [:deerjikists, :materials,
|
||||
:tag_names, :tag_descriptions] }])
|
||||
@@ -66,12 +56,25 @@ class PostsController < ApplicationController
|
||||
end
|
||||
q = q.where('posts.created_at >= ?', created_between[0]) if created_between[0]
|
||||
q = q.where('posts.created_at <= ?', created_between[1]) if created_between[1]
|
||||
count_scope = q
|
||||
if needs_history_join
|
||||
history_times = HistoryAccess.visible(PostVersion.all)
|
||||
.select('post_id, MAX(created_at) AS latest_at').group(:post_id)
|
||||
q = q.joins("LEFT JOIN (#{ history_times.to_sql }) history_times " +
|
||||
'ON history_times.post_id = posts.id')
|
||||
updated_at_all_sql = 'GREATEST(posts.updated_at, ' +
|
||||
'COALESCE(history_times.latest_at, posts.updated_at))'
|
||||
end
|
||||
if updated_between[0]
|
||||
q = q.where("#{ updated_at_all_sql } >= ?", updated_between[0])
|
||||
end
|
||||
if updated_between[1]
|
||||
q = q.where("#{ updated_at_all_sql } <= ?", updated_between[1])
|
||||
end
|
||||
count_scope = q if updated_between.any?(&:present?)
|
||||
if needs_history_join
|
||||
q = q.select('posts.*', "#{ updated_at_all_sql } AS updated_at")
|
||||
end
|
||||
|
||||
sort_sql =
|
||||
case order[0]
|
||||
@@ -88,12 +91,20 @@ class PostsController < ApplicationController
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
.to_a
|
||||
latest_version_times =
|
||||
if needs_history_join
|
||||
{ }
|
||||
else
|
||||
HistoryAccess.visible(PostVersion.where(post_id: posts.map(&:id)))
|
||||
.group(:post_id).maximum(:created_at)
|
||||
end
|
||||
|
||||
q = q.except(:select, :order)
|
||||
count_scope = count_scope.except(:select, :order)
|
||||
|
||||
render json: { posts: posts.map { |post|
|
||||
updated_at = [post.updated_at, latest_version_times[post.id]].compact.max
|
||||
PostRepr.base(post, language_code: locale.language_code)
|
||||
.merge(updated_at: post.updated_at_all).tap do |json|
|
||||
.merge('updated_at' => updated_at).tap do |json|
|
||||
json['thumbnail'] =
|
||||
if post.thumbnail.attached?
|
||||
rails_storage_proxy_url(post.thumbnail, only_path: false)
|
||||
@@ -101,7 +112,7 @@ class PostsController < ApplicationController
|
||||
nil
|
||||
end
|
||||
end
|
||||
}, count: q.group_values.present? ? q.count.size : q.count }
|
||||
}, count: count_scope.group_values.present? ? count_scope.count.size : count_scope.count }
|
||||
end
|
||||
|
||||
def random
|
||||
@@ -418,7 +429,7 @@ class PostsController < ApplicationController
|
||||
filters = literals.map do |literal|
|
||||
internal, = source_ids.fetch(literal[:name])
|
||||
name = internal.empty? ? literal[:name] : names[internal.first]
|
||||
return head :unprocessable_entity unless name && internal.length <= 1
|
||||
return head :unprocessable_content unless name && internal.length <= 1
|
||||
|
||||
literal[:negative] ? "not:#{ name }" : name
|
||||
end
|
||||
@@ -427,7 +438,7 @@ class PostsController < ApplicationController
|
||||
literals.zip(translated).each do |literal, translation|
|
||||
unless (source_ids.fetch(literal[:name]).map(&:sort) ==
|
||||
target_ids.fetch(translation[:name]).map(&:sort))
|
||||
return head :unprocessable_entity
|
||||
return head :unprocessable_content
|
||||
end
|
||||
end
|
||||
index = 0
|
||||
|
||||
@@ -55,7 +55,7 @@ class TheatresController < ApplicationController
|
||||
theatre = Theatre.find_by(id: params[:id])
|
||||
return head :not_found unless theatre
|
||||
requested_post_id = params[:post_id].to_i
|
||||
return head :unprocessable_entity if requested_post_id <= 0
|
||||
return head :unprocessable_content if requested_post_id <= 0
|
||||
|
||||
skipped = false
|
||||
conflicted = false
|
||||
@@ -95,7 +95,7 @@ class TheatresController < ApplicationController
|
||||
theatre = Theatre.find_by(id: params[:id])
|
||||
return head :not_found unless theatre
|
||||
requested_post_id = params[:post_id].to_i
|
||||
return head :unprocessable_entity if requested_post_id <= 0
|
||||
return head :unprocessable_content if requested_post_id <= 0
|
||||
|
||||
conflicted = false
|
||||
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
class ExternalTag < ApplicationRecord
|
||||
before_create { SharedResourceIdAllocator.assign!(self) }
|
||||
# Must run before dependent association callbacks.
|
||||
before_destroy :prevent_destroy
|
||||
|
||||
enum :platform, nico: 'nico'
|
||||
validates :platform, presence: true, inclusion: { in: ExternalTag.platforms.keys }
|
||||
validate :immutable_identity, on: :update
|
||||
|
||||
has_many :post_external_tags, dependent: :delete_all
|
||||
has_many :posts, through: :post_external_tags
|
||||
@@ -16,11 +19,21 @@ class ExternalTag < ApplicationRecord
|
||||
|
||||
has_many :linked_tags, through: :nico_tag_relations, source: :tag
|
||||
|
||||
def snapshot_linked_tag_names language_code = 'ja'
|
||||
linked_tags
|
||||
.joins(:tag_names)
|
||||
.where(tag_names: { language_code:, primary_flg: true })
|
||||
.order('tag_names.name')
|
||||
.pluck('tag_names.name')
|
||||
def snapshot_linked_tag_ids = linked_tags.distinct.order(:id).pluck(:id)
|
||||
|
||||
def delete
|
||||
raise ActiveRecord::ReadOnlyRecord, '外部タグは削除できません.'
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def immutable_identity
|
||||
errors.add(:platform, 'は変更できません.') if will_save_change_to_platform?
|
||||
errors.add(:name, 'は変更できません.') if will_save_change_to_name?
|
||||
end
|
||||
|
||||
def prevent_destroy
|
||||
errors.add(:base, '外部タグは削除できません.')
|
||||
throw(:abort)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -4,7 +4,7 @@ require 'digest'
|
||||
class HistoryAccess < ActiveSupport::CurrentAttributes
|
||||
attribute :user
|
||||
class Forbidden < StandardError; end
|
||||
ARTIFACT_POLICY = 'counts-masked-summary-1'.freeze
|
||||
ARTIFACT_POLICY = 'counts-private-predicates-4'.freeze
|
||||
|
||||
KEYS = { 'post_versions' => ['post_id'], 'tag_versions' => ['tag_id'],
|
||||
'nico_tag_versions' => ['tag_id'],
|
||||
@@ -59,20 +59,21 @@ class HistoryAccess < ActiveSupport::CurrentAttributes
|
||||
end
|
||||
|
||||
def self.json_reference(table, key, document)
|
||||
"NOT EXISTS (SELECT 1 FROM JSON_TABLE(COALESCE(#{ document }, JSON_ARRAY()), " +
|
||||
# A scalar lookup avoids MySQL's semijoin rewrite losing the JSON row correlation.
|
||||
"(SELECT 1 FROM JSON_TABLE(COALESCE(#{ document }, JSON_ARRAY()), " +
|
||||
"'$[*]' COLUMNS (reference_id BIGINT PATH '$' NULL ON EMPTY)) reference_entry " +
|
||||
"JOIN (#{ discarded_keys(table, key) }) blocked " +
|
||||
"ON reference_entry.reference_id = blocked.#{ key })"
|
||||
"ON reference_entry.reference_id = blocked.#{ key } LIMIT 1) IS NULL"
|
||||
end
|
||||
|
||||
def self.list_reference(table, key, document)
|
||||
# Preserve FIND_IN_SET's exact token semantics, including empty tokens.
|
||||
array = "CONCAT('[', REPLACE(JSON_QUOTE(REPLACE(COALESCE(#{ document }, ''), " +
|
||||
"' ', ',')), ',', '\",\"'), ']')"
|
||||
"NOT EXISTS (SELECT 1 FROM JSON_TABLE(#{ array }, '$[*]' " +
|
||||
"(SELECT 1 FROM JSON_TABLE(#{ array }, '$[*]' " +
|
||||
"COLUMNS (reference_id VARCHAR(255) PATH '$')) reference_entry " +
|
||||
"JOIN (#{ discarded_keys(table, key) }) blocked " +
|
||||
"ON reference_entry.reference_id = CAST(blocked.#{ key } AS CHAR))"
|
||||
"ON reference_entry.reference_id = CAST(blocked.#{ key } AS CHAR) LIMIT 1) IS NULL"
|
||||
end
|
||||
|
||||
def self.snapshot_reference(model, row, external: false)
|
||||
@@ -91,8 +92,8 @@ class HistoryAccess < ActiveSupport::CurrentAttributes
|
||||
match += ' AND reference_version.version_no = tag_reference.version_no'
|
||||
latest = ''
|
||||
end
|
||||
"NOT EXISTS (SELECT 1 FROM #{ source } JOIN #{ table } reference_version ON #{ match } " +
|
||||
"WHERE NOT (#{ sql(model, 'reference_version') }) #{ latest })"
|
||||
"(SELECT 1 FROM #{ source } JOIN #{ table } reference_version ON #{ match } " +
|
||||
"WHERE NOT (#{ sql(model, 'reference_version') }) #{ latest } LIMIT 1) IS NULL"
|
||||
end
|
||||
|
||||
def self.reference_sql(table, row)
|
||||
@@ -105,17 +106,7 @@ class HistoryAccess < ActiveSupport::CurrentAttributes
|
||||
snapshot_reference(TagVersion, row),
|
||||
snapshot_reference(NicoTagVersion, row, external: true)]
|
||||
when 'nico_tag_versions'
|
||||
# Legacy linked_tags snapshots contain names, not IDs. Match recorded names
|
||||
# conservatively; never expose a private linked name through an external tag.
|
||||
names = "JSON_MERGE_PRESERVE(JSON_ARRAY(private_tag.name), " +
|
||||
"COALESCE(JSON_EXTRACT(private_tag.names, '$**.name'), JSON_ARRAY()))"
|
||||
["NOT EXISTS (SELECT 1 FROM tag_versions private_tag " +
|
||||
"JOIN (#{ discarded_keys('tag_versions', 'tag_id') }) blocked " +
|
||||
"ON blocked.tag_id = private_tag.tag_id " +
|
||||
"JOIN JSON_TABLE(#{ names }, '$[*]' COLUMNS (name TEXT PATH '$')) private_name " +
|
||||
"WHERE private_name.name <> '' AND " +
|
||||
"LOCATE(CONCAT(' ', private_name.name, ' '), " +
|
||||
"CONCAT(' ', COALESCE(#{ row }.linked_tags, ''), ' ')) > 0)"]
|
||||
[list_reference('tag_versions', 'tag_id', "#{ row }.linked_tag_ids")]
|
||||
when 'tag_versions'
|
||||
[list_reference('tag_versions', 'tag_id', "#{ row }.parent_tag_ids")]
|
||||
when 'tag_description_versions'
|
||||
|
||||
@@ -3,5 +3,10 @@ class NicoTagVersion < ApplicationRecord
|
||||
|
||||
belongs_to :external_tag, foreign_key: :tag_id, inverse_of: :nico_tag_versions
|
||||
|
||||
validates :name, presence: true
|
||||
validates :event_type, inclusion: { in: ['create', 'update'] }
|
||||
|
||||
def name
|
||||
tag = external_tag
|
||||
"#{ tag.platform }:#{ tag.name }" if tag
|
||||
end
|
||||
end
|
||||
|
||||
@@ -139,6 +139,8 @@ class CountsVersionRepr
|
||||
result = {}
|
||||
table = model.table_name
|
||||
comparison = @inclusive ? '<=' : '<'
|
||||
name = model == NicoTagVersion ?
|
||||
CountsResourceDefinition.new('nico_tags').expression('name', table) : 'name'
|
||||
requests.each_slice(100) do |batch|
|
||||
queries = batch.map do |id, point|
|
||||
scope = HistoryAccess.visible(model.where(tag_id: id))
|
||||
@@ -148,7 +150,8 @@ class CountsVersionRepr
|
||||
"AND newer.version_no > #{ table }.version_no)", point)
|
||||
quoted = model.connection.quote(point.iso8601(6))
|
||||
names = model == TagVersion ? 'names' : 'JSON_OBJECT() AS names'
|
||||
scope.select("tag_id, name, #{ names }, #{ quoted } AS reference_point").to_sql
|
||||
scope.select("tag_id, #{ name } AS name, #{ names }, " +
|
||||
"#{ quoted } AS reference_point").to_sql
|
||||
end
|
||||
next if queries.empty?
|
||||
|
||||
|
||||
@@ -1,12 +1,41 @@
|
||||
class CountsAxes
|
||||
MAX_AXES = 8
|
||||
MASKED_TEXT = '********'.freeze
|
||||
MASKED_VALUE = '__counts_private__'.freeze
|
||||
PUBLIC_FIELDS = ['created_at', 'event_type'].freeze
|
||||
PUBLIC_FIELDS = ['resource_id', 'version_no', 'event_type', 'user',
|
||||
'created_at'].freeze
|
||||
class TooManyAxes < ArgumentError; end
|
||||
class PrivateCondition < ArgumentError; end
|
||||
|
||||
GRAINS = ['year', 'quarter', 'month', 'week', 'day', 'hour'].freeze
|
||||
|
||||
def self.private_field?(field)
|
||||
!PUBLIC_FIELDS.include?(field) && field != 'user.id'
|
||||
end
|
||||
|
||||
def self.visible_ids_sql(definition)
|
||||
"SELECT counts_visible.id FROM #{ definition.table } counts_visible WHERE " +
|
||||
HistoryAccess.sql(definition.model, 'counts_visible')
|
||||
end
|
||||
|
||||
def self.visibility_sql(definition, field)
|
||||
return 'TRUE' if HistoryAccess.admin?
|
||||
|
||||
source = visible_ids_sql(definition)
|
||||
visible = "v.id IN (#{ source })"
|
||||
return visible unless field == 'changed_field'
|
||||
|
||||
"(#{ visible }) AND (previous.id IS NULL OR previous.id IN (#{ source }))"
|
||||
end
|
||||
|
||||
def self.masked_sql(expression, definition, field)
|
||||
return expression if HistoryAccess.admin? || !private_field?(field)
|
||||
|
||||
visible = visibility_sql(definition, field)
|
||||
quoted = ApplicationRecord.connection.quote(MASKED_VALUE)
|
||||
"CASE WHEN #{ visible } THEN #{ expression } ELSE #{ quoted } END"
|
||||
end
|
||||
|
||||
def self.time_expression(expression, grain, timezone)
|
||||
raise ArgumentError unless GRAINS.include?(grain)
|
||||
|
||||
@@ -60,13 +89,9 @@ class CountsAxes
|
||||
@definition.fields[field] == :datetime ? self.class.time_expression(
|
||||
expression, axis.fetch('grain', 'month'), @population.timezone) : expression
|
||||
end
|
||||
if !HistoryAccess.admin? && !PUBLIC_FIELDS.include?(field)
|
||||
visible = HistoryAccess.sql(@definition.model, 'v')
|
||||
if field == 'changed_field'
|
||||
visible = "(#{ visible }) AND (#{ HistoryAccess.sql(@definition.model, 'previous') })"
|
||||
end
|
||||
expression = "CASE WHEN #{ visible } " +
|
||||
"THEN CAST((#{ expression }) AS CHAR) ELSE '#{ MASKED_VALUE }' END"
|
||||
if !HistoryAccess.admin? && self.class.private_field?(field)
|
||||
expression = self.class.masked_sql(
|
||||
"CAST((#{ expression }) AS CHAR)", @definition, field)
|
||||
end
|
||||
expression
|
||||
end
|
||||
@@ -77,10 +102,12 @@ class CountsAxes
|
||||
selected = indexes.map { "#{ expressions[_1] } AS a#{ _1 }" }
|
||||
identities = ['v.id']
|
||||
if rows.any? { _1['field'] == 'tags' }
|
||||
# Raw identities stay inside COUNT; masked tag occurrences remain additive.
|
||||
identities << "COALESCE(CONCAT('internal:', tag_entry.tag_id), " +
|
||||
"CONCAT('external:', tag_entry.external_tag_id))"
|
||||
identities << 'changed.field_key' if rows.any? { _1['field'] == 'changed_field' }
|
||||
identities << self.class.masked_sql(
|
||||
"COALESCE(CONCAT('internal:', tag_entry.tag_id), " +
|
||||
"CONCAT('external:', tag_entry.external_tag_id))", @definition, 'tags')
|
||||
if rows.any? { _1['field'] == 'changed_field' }
|
||||
identities << self.class.masked_sql('changed.field_key', @definition, 'changed_field')
|
||||
end
|
||||
end
|
||||
members = identities.each_index.map { "counts_member_#{ _1 }" }
|
||||
selected += identities.each_with_index.map { |expression, index|
|
||||
@@ -98,8 +125,17 @@ class CountsAxes
|
||||
def joins(fields = all.map { _1['field'] })
|
||||
result = ''
|
||||
if fields.include?('tags')
|
||||
document = 'CAST(v.tags_json AS JSON)'
|
||||
unless HistoryAccess.admin?
|
||||
visible = self.class.visible_ids_sql(@definition)
|
||||
result += " LEFT JOIN (#{ visible }) counts_tags_visible " +
|
||||
'ON counts_tags_visible.id = v.id '
|
||||
document = "CASE WHEN counts_tags_visible.id IS NOT NULL " +
|
||||
"THEN #{ document } ELSE JSON_ARRAY(" +
|
||||
"JSON_OBJECT('tag_id', 0)) END"
|
||||
end
|
||||
result += <<~SQL.squish
|
||||
JOIN JSON_TABLE(CAST(v.tags_json AS JSON), '$[*]' COLUMNS (
|
||||
JOIN JSON_TABLE(#{ document }, '$[*]' COLUMNS (
|
||||
tag_id BIGINT PATH '$.tag_id' NULL ON EMPTY,
|
||||
external_tag_id BIGINT PATH '$.external_tag_id' NULL ON EMPTY)) tag_entry
|
||||
|
||||
@@ -112,6 +148,13 @@ class CountsAxes
|
||||
condition = @definition.changes.map do |field|
|
||||
"(changed.field_key = '#{ field }' AND #{ @population.changed_sql(field) })"
|
||||
end.join(' OR ')
|
||||
unless HistoryAccess.admin?
|
||||
visible = self.class.visibility_sql(@definition, 'changed_field')
|
||||
marker = ApplicationRecord.connection.quote(MASKED_VALUE)
|
||||
fields += " UNION ALL SELECT #{ marker } AS field_key"
|
||||
condition = "((#{ visible }) AND (#{ condition })) OR " +
|
||||
"(NOT (#{ visible }) AND changed.field_key = #{ marker })"
|
||||
end
|
||||
result += " JOIN (#{ fields }) changed ON (#{ condition })"
|
||||
end
|
||||
result
|
||||
|
||||
@@ -28,16 +28,18 @@ class CountsCandidates
|
||||
[external, 'external', 'NULL', 'JSON_OBJECT()']]
|
||||
sources = sources.map do |population, kind, category, names|
|
||||
scope = population.scope.except(:select)
|
||||
name = population.definition.expression('name')
|
||||
if @params['query'].present?
|
||||
pattern = "%#{ ApplicationRecord.sanitize_sql_like(@params['query']) }%"
|
||||
scope = scope.where("(v.name LIKE ? OR CAST(v.tag_id AS CHAR) = ? OR EXISTS (" +
|
||||
scope = scope.where("(#{ name } LIKE ? OR CAST(v.tag_id AS CHAR) = ? OR EXISTS (" +
|
||||
"SELECT 1 FROM JSON_TABLE(COALESCE(JSON_EXTRACT(#{ names }, '$**.name'), " +
|
||||
"JSON_ARRAY()), '$[*]' COLUMNS (name TEXT PATH '$')) candidate_name " +
|
||||
'WHERE candidate_name.name LIKE ?))', pattern, @params['query'], pattern)
|
||||
end
|
||||
scope.select(Arel.sql(
|
||||
"CONCAT('#{ kind }:', v.tag_id) AS value, v.tag_id AS id, " +
|
||||
"v.name AS name, #{ names } AS names, #{ category } AS category, '#{ kind }' AS kind"))
|
||||
"#{ name } AS name, #{ names } AS names, " +
|
||||
"#{ category } AS category, '#{ kind }' AS kind"))
|
||||
.to_sql
|
||||
end
|
||||
clauses = []
|
||||
|
||||
@@ -82,14 +82,32 @@ class CountsCondition
|
||||
|
||||
def apply(scope, entry)
|
||||
raise ArgumentError unless entry.is_a?(Hash)
|
||||
|
||||
if entry['operator'] == 'axis_eq'
|
||||
raise ArgumentError unless (entry.keys - ['field', 'operator', 'grain', 'value']).empty?
|
||||
value = entry['value']
|
||||
unless value.nil? || value.is_a?(String) || value.is_a?(Numeric) || [true, false].include?(value)
|
||||
unless (value.nil? || value.is_a?(String) || value.is_a?(Numeric) ||
|
||||
[true, false].include?(value))
|
||||
raise ArgumentError
|
||||
end
|
||||
raise ArgumentError if value.is_a?(String) && value.bytesize > 8192
|
||||
|
||||
if (!HistoryAccess.admin? && CountsAxes.private_field?(entry['field']) &&
|
||||
(value == CountsAxes::MASKED_VALUE ||
|
||||
(value == CountsAxes::MASKED_TEXT &&
|
||||
(@definition.fields[entry['field']] != :string ||
|
||||
['tags', 'changed_field'].include?(entry['field'])))))
|
||||
field = entry.fetch('field')
|
||||
raise ArgumentError unless @definition.axes(@population.history).include?(field)
|
||||
if entry.key?('grain')
|
||||
unless (@definition.fields[field] == :datetime &&
|
||||
CountsAxes::GRAINS.include?(entry['grain']))
|
||||
raise ArgumentError
|
||||
end
|
||||
end
|
||||
return scope.none
|
||||
end
|
||||
scope = @population.condition_scope(scope, entry.fetch('field'))
|
||||
return @population.axis_condition(scope, entry)
|
||||
end
|
||||
allowed = ['field', 'operator', 'value', 'end', 'blank', 'input', 'values']
|
||||
@@ -111,6 +129,13 @@ class CountsCondition
|
||||
|
||||
source, expression = source_for(field[:key])
|
||||
type = field[:type]
|
||||
# Preserve typed input compatibility; display markers are never real typed values.
|
||||
if (!HistoryAccess.admin? && CountsAxes.private_field?(field[:key]) &&
|
||||
entry['value'] == CountsAxes::MASKED_TEXT && type != :string && !entry['blank'])
|
||||
return scope.none unless entry['operator'] == 'ne'
|
||||
|
||||
return @population.condition_scope(scope, field[:key])
|
||||
end
|
||||
blank = blank_sql(expression, type)
|
||||
if entry['blank']
|
||||
sql = if source && field[:collection]
|
||||
@@ -119,6 +144,7 @@ class CountsCondition
|
||||
"NOT EXISTS (SELECT 1 FROM #{ source } WHERE NOT (#{ blank }))"
|
||||
else blank
|
||||
end
|
||||
scope = @population.condition_scope(scope, field[:key])
|
||||
return scope.where(Arel.sql(sql))
|
||||
end
|
||||
if ['in', 'not_in', 'any', 'all', 'none'].include?(entry['operator'])
|
||||
@@ -131,6 +157,7 @@ class CountsCondition
|
||||
validate_enum!(field[:key], parsed.first) if type == :enum
|
||||
operator = entry['operator']
|
||||
if type == :interval
|
||||
scope = @population.condition_scope(scope, field[:key])
|
||||
return interval(scope, source, expression, parsed)
|
||||
end
|
||||
clauses = []
|
||||
@@ -158,6 +185,7 @@ class CountsCondition
|
||||
elsif operator == 'ne'
|
||||
predicate = "(NOT (#{ predicate }) OR #{ blank })"
|
||||
end
|
||||
scope = @population.condition_scope(scope, field[:key])
|
||||
scope.where(predicate, *binds)
|
||||
end
|
||||
|
||||
@@ -168,6 +196,13 @@ class CountsCondition
|
||||
|
||||
values = entry['values']
|
||||
raise ArgumentError unless values.is_a?(Array) && values.length <= 100
|
||||
if (!HistoryAccess.admin? && CountsAxes.private_field?(field[:key]) &&
|
||||
values.include?(CountsAxes::MASKED_TEXT) &&
|
||||
(field[:type] != :string || field[:set_type] == 'tag_reference'))
|
||||
return scope.none if entry['operator'] == 'all'
|
||||
|
||||
values = values - [CountsAxes::MASKED_TEXT]
|
||||
end
|
||||
raise ArgumentError if [entry['value'], entry['end']].any? { !_1.nil? && _1 != '' }
|
||||
|
||||
parsed = values.map do |value|
|
||||
@@ -191,7 +226,10 @@ class CountsCondition
|
||||
|
||||
operator = entry['operator']
|
||||
if parsed.empty?
|
||||
return ['in', 'any'].include?(operator) ? scope.none : scope
|
||||
return scope.none if ['in', 'any'].include?(operator)
|
||||
return scope if entry['values'].empty?
|
||||
|
||||
return @population.condition_scope(scope, field[:key])
|
||||
end
|
||||
source, expression = source_for(field[:key])
|
||||
if field[:set_type] == 'tag_reference'
|
||||
@@ -212,6 +250,7 @@ class CountsCondition
|
||||
predicate = source ? "NOT #{ predicate }" :
|
||||
"(NOT #{ predicate } OR #{ blank_sql(expression, field[:type]) })"
|
||||
end
|
||||
scope = @population.condition_scope(scope, field[:key])
|
||||
scope.where(predicate, *parsed)
|
||||
end
|
||||
|
||||
|
||||
@@ -70,6 +70,13 @@ class CountsPopulation
|
||||
@authorisation_flg ? HistoryAccess.sql(model, row) : 'TRUE'
|
||||
end
|
||||
|
||||
# Each predicate keeps its own visibility restriction, including OR branches.
|
||||
def condition_scope(scope, field)
|
||||
return scope if HistoryAccess.admin? || !CountsAxes.private_field?(field)
|
||||
|
||||
scope.where(Arel.sql(CountsAxes.visibility_sql(definition, field)))
|
||||
end
|
||||
|
||||
def axis_condition(scope, entry)
|
||||
raise CountsAxes::PrivateCondition if entry['value'] == CountsAxes::MASKED_VALUE
|
||||
|
||||
@@ -106,7 +113,7 @@ class CountsPopulation
|
||||
else
|
||||
raise ArgumentError unless definition.axes(history).include?(key)
|
||||
|
||||
scope = scope.where(definition.expression(key) => entry['value'])
|
||||
scope = scope.where("#{ definition.expression(key) } <=> ?", entry['value'])
|
||||
end
|
||||
scope
|
||||
end
|
||||
@@ -209,6 +216,7 @@ class CountsPopulation
|
||||
def apply_tags
|
||||
raise ArgumentError unless definition.resource == 'posts'
|
||||
|
||||
@scope = condition_scope(@scope, 'tags')
|
||||
if params['tag'].present?
|
||||
@scope = @scope.where(snapshot_membership(
|
||||
"#{ params.fetch('kind', 'internal') }:#{ params['tag'] }"))
|
||||
|
||||
@@ -105,12 +105,25 @@ class CountsResourceDefinition
|
||||
def time_column = 'created_at'
|
||||
def column(key)
|
||||
raise ArgumentError unless fields.key?(key)
|
||||
# Keep the V1 Counts field name at the API boundary only.
|
||||
return 'linked_tag_ids' if resource == 'nico_tags' && key == 'linked_tags'
|
||||
|
||||
{ 'resource_id' => keys.first, 'user' => 'created_by_user_id' }.fetch(key, key)
|
||||
end
|
||||
|
||||
def expression(key, row = 'v') = "#{ row }.#{ column(key) }"
|
||||
def changes = fields.keys - COMMON.keys - ['updated_at', 'updated_by_user_id']
|
||||
def expression(key, row = 'v')
|
||||
if resource == 'nico_tags' && key == 'name'
|
||||
return "(SELECT CONCAT(external_tags.platform, ':', external_tags.name) " +
|
||||
"FROM external_tags WHERE external_tags.id = #{ row }.tag_id)"
|
||||
end
|
||||
|
||||
"#{ row }.#{ column(key) }"
|
||||
end
|
||||
|
||||
def changes
|
||||
columns = fields.keys - COMMON.keys - ['updated_at', 'updated_by_user_id']
|
||||
resource == 'nico_tags' ? columns - ['name'] : columns
|
||||
end
|
||||
def axes(history)
|
||||
fields.reject { |_key, type| type == :json }.keys +
|
||||
(resource == 'posts' ? ['tags'] : []) + (history ? ['changed_field'] : [])
|
||||
@@ -118,7 +131,9 @@ class CountsResourceDefinition
|
||||
|
||||
def metadata
|
||||
fields.map do |key, type|
|
||||
{ key:, type:, nullable: model.columns_hash.fetch(column(key)).null,
|
||||
nullable = (resource == 'nico_tags' && key == 'name') ||
|
||||
model.columns_hash.fetch(column(key)).null
|
||||
{ key:, type:, nullable:,
|
||||
operators: OPERATORS.fetch(type, []) }
|
||||
end
|
||||
end
|
||||
|
||||
@@ -201,6 +201,8 @@ class CountsStateQuery
|
||||
end
|
||||
|
||||
def listing
|
||||
external_name = CountsResourceDefinition.new('nico_tags')
|
||||
.expression('name', 'external_version')
|
||||
catalog = <<~SQL
|
||||
, tag_counts AS (
|
||||
SELECT membership.kind, membership.tag_id, COUNT(*) AS post_count
|
||||
@@ -209,7 +211,7 @@ class CountsStateQuery
|
||||
SELECT 'internal' AS kind, tag_id, name, names,
|
||||
deprecated_at FROM tag_at WHERE event_type <> 'discard'
|
||||
UNION ALL
|
||||
SELECT tc.kind, tc.tag_id, external_version.name, JSON_OBJECT() AS names,
|
||||
SELECT tc.kind, tc.tag_id, #{ external_name } AS name, JSON_OBJECT() AS names,
|
||||
NULL AS deprecated_at
|
||||
FROM tag_counts tc
|
||||
LEFT JOIN tag_at internal_version
|
||||
|
||||
@@ -253,7 +253,7 @@ class CountsTableExport
|
||||
end
|
||||
|
||||
def summary_value(value, index, page)
|
||||
return '********' if value == CountsAxes::MASKED_VALUE
|
||||
return CountsAxes::MASKED_TEXT if value == CountsAxes::MASKED_VALUE
|
||||
|
||||
field = @query.axes.all.fetch(index).fetch('field')
|
||||
return @labels.fetch('bot', '') if field == 'user' && value.nil?
|
||||
|
||||
@@ -182,6 +182,7 @@ class CountsTableQuery
|
||||
order = sort_sql { detail_expression(_1) }
|
||||
query = query.order(Arel.sql((order + ['v.id ASC']).join(', ')))
|
||||
.limit(limit).offset((page - 1) * limit)
|
||||
query = query.preload(:external_tag) if @definition.resource == 'nico_tags'
|
||||
rows = query.to_a
|
||||
if ['posts', 'tags'].include?(@definition.resource)
|
||||
point = population.history ? population.at :
|
||||
@@ -220,19 +221,11 @@ class CountsTableQuery
|
||||
|
||||
private
|
||||
|
||||
# Public timeline predicates may include hidden records. Private predicates must
|
||||
# never become a count oracle, even when the selected axes themselves are public.
|
||||
# Private predicates require visibility independently of their operator.
|
||||
# Ordering/measure state must not select a server-only masked coordinate.
|
||||
def validate_summary_conditions!
|
||||
return if mode != 'summary' || HistoryAccess.admin?
|
||||
|
||||
fields = QueryState.records(@params, 'conditions') + QueryState.records(@params, 'drill')
|
||||
private_fields = fields.any? { !CountsAxes::PUBLIC_FIELDS.include?(_1['field']) }
|
||||
keys = ['tags', 'tag', 'kind', 'resource_id', 'user_id']
|
||||
private_parameters = keys.any? do |key|
|
||||
@params.any? { |name, value| (name == key || name.start_with?("#{ key }.")) && value != '' }
|
||||
end
|
||||
raise CountsAxes::PrivateCondition if private_fields || private_parameters
|
||||
|
||||
['column_order', 'measures'].each do |key|
|
||||
# Coordinates are compared only to already masked expressions, never raw
|
||||
# values. Public coordinates remain sortable; a guessed private ID cannot match.
|
||||
@@ -494,6 +487,7 @@ class CountsTableQuery
|
||||
|
||||
table = model.table_name
|
||||
scope = HistoryAccess.visible(model.where(tag_id: ids)).where('created_at <= ?', point)
|
||||
scope = scope.includes(:external_tag) if model == NicoTagVersion
|
||||
bound = @connection.quote(point)
|
||||
if population.history && @definition.resource == 'posts'
|
||||
key = kind == 'internal' ? 'tag_id' : 'external_tag_id'
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
class NicoTagVersionRecorder < VersionRecorder
|
||||
EVENT_TYPES = ['create', 'update'].freeze
|
||||
def self.record! external_tag:, event_type:, created_by_user:
|
||||
new(external_tag:, event_type:, created_by_user:).record!
|
||||
end
|
||||
@@ -14,8 +15,7 @@ class NicoTagVersionRecorder < VersionRecorder
|
||||
def record_key = :external_tag
|
||||
|
||||
def snapshot_attributes
|
||||
{ name: "#{ @record.platform }:#{ @record.name }",
|
||||
linked_tags: @record.snapshot_linked_tag_names.join(' ') }
|
||||
{ linked_tag_ids: @record.snapshot_linked_tag_ids.join(' ') }
|
||||
end
|
||||
|
||||
def tracks_version_no_on_record? = false
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
class AddLinkedTagIdsToNicoTagVersions < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
add_column :nico_tag_versions, :linked_tag_ids, :text
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,42 @@
|
||||
class MigrateLinkedTagsInNicoTagVersions < ActiveRecord::Migration[8.0]
|
||||
class MigrationTagVersion < ActiveRecord::Base
|
||||
self.table_name = 'tag_versions'
|
||||
end
|
||||
|
||||
class MigrationNicoTagVersion < ActiveRecord::Base
|
||||
self.table_name = 'nico_tag_versions'
|
||||
end
|
||||
|
||||
def up
|
||||
# 除票外部タグの恢復はないので除票履歴があれば全消しでよいはず.
|
||||
execute <<~SQL
|
||||
DELETE
|
||||
t1
|
||||
FROM
|
||||
nico_tag_versions t1
|
||||
INNER JOIN
|
||||
nico_tag_versions t2
|
||||
ON
|
||||
t1.tag_id = t2.tag_id
|
||||
AND t2.event_type = 'discard'
|
||||
SQL
|
||||
|
||||
MigrationNicoTagVersion.find_each do |ntv|
|
||||
linked_tag_ids = ntv.linked_tags.split.map { |name|
|
||||
# 厳密には一意ぢゃないけどめんだぅだからこれでいいや.
|
||||
MigrationTagVersion
|
||||
.order(created_at: :desc, id: :desc)
|
||||
.find_by("names ->> '$.ja.name' = ?", name)
|
||||
&.tag_id
|
||||
}.compact.uniq.sort.join(' ')
|
||||
|
||||
ntv.update_columns(linked_tag_ids:)
|
||||
end
|
||||
|
||||
change_column_null :nico_tag_versions, :linked_tag_ids, false
|
||||
end
|
||||
|
||||
def down
|
||||
raise ActiveRecord::IrreversibleMigration, '戻せません.'
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,6 @@
|
||||
class RemoveLinkedTagsFromNicoTagVersions < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
remove_column :nico_tag_versions, :linked_tags
|
||||
remove_column :nico_tag_versions, :name
|
||||
end
|
||||
end
|
||||
生成ファイル
+2
-3
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[8.0].define(version: 2026_10_15_010000) do
|
||||
ActiveRecord::Schema[8.0].define(version: 2026_11_08_020000) do
|
||||
create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||
t.string "name", null: false
|
||||
t.string "record_type", null: false
|
||||
@@ -301,10 +301,9 @@ ActiveRecord::Schema[8.0].define(version: 2026_10_15_010000) do
|
||||
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.text "linked_tag_ids", null: false
|
||||
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 }
|
||||
|
||||
+15
-2
@@ -9,8 +9,21 @@
|
||||
# end
|
||||
|
||||
ApplicationRecord.transaction do
|
||||
Language.find_or_create_by!(code: 'ain') { _1.name = 'ain' }
|
||||
Script.find_or_create_by!(code: 'Kana') { _1.name = 'Kana' }
|
||||
Language.find_or_create_by!(code: 'ja') { _1.name = '日本語' }
|
||||
Script.find_or_create_by!(code: 'Jpan') { _1.name = '漢字、仮名文字およびラテン文字' }
|
||||
Region.find_or_create_by!(code: 392) do
|
||||
_1.alpha_code = 'JP'
|
||||
_1.name = '日本国'
|
||||
end
|
||||
Locale.find_or_create_by!(code: 'ja') do
|
||||
_1.language_code = 'ja'
|
||||
_1.script_code = 'Jpan'
|
||||
_1.region_code = 392
|
||||
_1.name = '日本語'
|
||||
end
|
||||
|
||||
Language.find_or_create_by!(code: 'ain') { _1.name = 'ヤウンモシルン アイヌ イタㇰ' }
|
||||
Script.find_or_create_by!(code: 'Kana') { _1.name = 'カナモジ オヨビ ラテンモジ' }
|
||||
end
|
||||
|
||||
post_url_sanitisation_rules = [
|
||||
|
||||
@@ -11,28 +11,63 @@ RSpec.describe ExternalTag, type: :model do
|
||||
expect(external.reload.name).to eq('raw tag[]')
|
||||
end
|
||||
|
||||
it 'deletes post associations without deleting posts or version history' do
|
||||
it 'does not allow a persisted external tag to change its platform or name' do
|
||||
external = create(:external_tag, name: 'immutable')
|
||||
|
||||
expect { external.update!(name: 'renamed') }
|
||||
.to raise_error(ActiveRecord::RecordInvalid)
|
||||
expect(external.errors[:name]).to include('は変更できません.')
|
||||
|
||||
external.reload
|
||||
external.platform = nil
|
||||
expect { external.save! }.to raise_error(ActiveRecord::RecordInvalid)
|
||||
expect(external.errors[:platform]).to include('は変更できません.')
|
||||
|
||||
expect(external.reload).to have_attributes(platform: 'nico', name: 'immutable')
|
||||
end
|
||||
|
||||
it 'rejects delete, which bypasses destroy callbacks' do
|
||||
external = create(:external_tag)
|
||||
|
||||
expect { external.delete }.to raise_error(ActiveRecord::ReadOnlyRecord)
|
||||
expect(external.reload).to be_persisted
|
||||
end
|
||||
|
||||
it 'increments the counter through post associations without changing identity' do
|
||||
external = create(:external_tag, name: 'counter')
|
||||
post = create(:post)
|
||||
|
||||
expect {
|
||||
PostExternalTag.create!(post:, external_tag: external)
|
||||
}.to change { external.reload.post_count }.by(1)
|
||||
|
||||
expect(external.reload).to have_attributes(platform: 'nico', name: 'counter')
|
||||
end
|
||||
|
||||
it 'does not destroy external tags or their post associations and history' do
|
||||
external = create(:external_tag)
|
||||
post = create(:post)
|
||||
PostExternalTag.create!(post:, external_tag: external)
|
||||
version = NicoTagVersionRecorder.record!(
|
||||
external_tag: external, event_type: :create, created_by_user: nil)
|
||||
|
||||
external.destroy!
|
||||
expect { external.destroy! }.to raise_error(ActiveRecord::RecordNotDestroyed)
|
||||
|
||||
expect(PostExternalTag.where(external_tag_id: external.id)).to be_empty
|
||||
expect(post.reload.external_tags).to be_empty
|
||||
expect(external.reload).to be_persisted
|
||||
expect(PostExternalTag.exists?(post:, external_tag: external)).to be(true)
|
||||
expect(post.reload.external_tags).to contain_exactly(external)
|
||||
expect(version.reload.tag_id).to eq(external.id)
|
||||
end
|
||||
|
||||
it 'deletes external links without deleting linked internal tags' do
|
||||
it 'does not destroy an external tag or its links to internal tags' do
|
||||
external = create(:external_tag)
|
||||
tag = create(:tag)
|
||||
NicoTagRelation.create!(nico_tag: external, tag:)
|
||||
relation = NicoTagRelation.create!(nico_tag: external, tag:)
|
||||
|
||||
external.destroy!
|
||||
expect { external.destroy! }.to raise_error(ActiveRecord::RecordNotDestroyed)
|
||||
|
||||
expect(NicoTagRelation.where(nico_tag_id: external.id)).to be_empty
|
||||
expect(external.reload).to be_persisted
|
||||
expect(relation.reload).to be_persisted
|
||||
expect(Tag.exists?(tag.id)).to be(true)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -76,7 +76,7 @@ RSpec.describe 'Counts artifact policy and external references' do
|
||||
end
|
||||
end
|
||||
|
||||
it 'does not expose a private linked tag name through an external history snapshot' do
|
||||
it 'does not expose a private linked tag ID through an external history snapshot' do
|
||||
tag = create(:tag, primary_name: 'private-linked-name')
|
||||
TagVersion.create!(tag:, version_no: 1, event_type: 'discard', name: 'private-linked-name',
|
||||
category: 'general', aliases: '', parent_tag_ids: '', created_at: Time.utc(2024),
|
||||
@@ -84,7 +84,7 @@ RSpec.describe 'Counts artifact policy and external references' do
|
||||
auto_generated: false, aliases: [] } })
|
||||
external = create(:external_tag, name: 'public-external')
|
||||
row = NicoTagVersion.create!(external_tag: external, version_no: 1, event_type: 'create',
|
||||
name: 'nico:public-external', linked_tags: 'private-linked-name',
|
||||
linked_tag_ids: tag.id.to_s,
|
||||
created_at: Time.utc(2024))
|
||||
post = create(:post)
|
||||
snapshot = PostVersion.create!(post:, version_no: 1, event_type: 'create',
|
||||
@@ -99,7 +99,7 @@ RSpec.describe 'Counts artifact policy and external references' do
|
||||
expect(HistoryAccess.visible(NicoTagVersion.where(id: row.id))).to exist
|
||||
end
|
||||
public_row = NicoTagVersion.create!(external_tag: external, version_no: 2, event_type: 'update',
|
||||
name: 'nico:public-external', linked_tags: 'private-linked-name-extra',
|
||||
linked_tag_ids: '',
|
||||
created_at: Time.utc(2024, 7))
|
||||
HistoryAccess.set(user: nil) do
|
||||
expect(HistoryAccess.visible(NicoTagVersion.where(id: public_row.id))).to exist
|
||||
|
||||
@@ -10,23 +10,22 @@ RSpec.describe 'Shared Tag and ExternalTag IDs', type: :model do
|
||||
expect(next_tag.id).to be > external.id
|
||||
end
|
||||
|
||||
it 'does not reuse physically deleted IDs in either table' do
|
||||
it 'does not reuse IDs after internal deletion while external tags remain' do
|
||||
tag = create(:tag)
|
||||
external = create(:external_tag)
|
||||
TagName.where(tag_id: tag.id).delete_all
|
||||
tag.destroy!
|
||||
external.destroy!
|
||||
expect(ExternalTag.exists?(external.id)).to be(true)
|
||||
|
||||
expect(create(:tag).id).to be > external.id
|
||||
expect(create(:external_tag).id).to be > external.id
|
||||
end
|
||||
|
||||
it 'retains existing overlapping IDs without renumbering on update or insert' do
|
||||
it 'retains overlapping IDs after a tag update and a new allocation' do
|
||||
tag = create(:tag)
|
||||
external = create(:external_tag)
|
||||
external.update_columns(id: tag.id)
|
||||
tag.update!(category: :meta)
|
||||
external.update!(name: 'updated_external')
|
||||
before_tag = tag.reload.attributes
|
||||
before_external = external.reload.attributes
|
||||
|
||||
|
||||
@@ -47,24 +47,34 @@ RSpec.describe VersionRecord, type: :model do
|
||||
external_tag: nico_tag,
|
||||
version_no: 1,
|
||||
event_type: 'create',
|
||||
name: "nico:#{ nico_tag.name }",
|
||||
linked_tags: '',
|
||||
linked_tag_ids: '',
|
||||
created_at: Time.current,
|
||||
created_by_user: nil
|
||||
)
|
||||
|
||||
expect {
|
||||
version.update!(name: 'nico:changed')
|
||||
version.update!(linked_tag_ids: '123')
|
||||
}.to raise_error(ActiveRecord::ReadOnlyRecord)
|
||||
end
|
||||
|
||||
|
||||
it 'rejects discard and restore versions for external tags' do
|
||||
[:discard, :restore].each do |event_type|
|
||||
version = NicoTagVersion.new(
|
||||
external_tag: nico_tag, version_no: 1, event_type:,
|
||||
linked_tag_ids: '', created_at: Time.current)
|
||||
|
||||
expect(version).not_to be_valid
|
||||
expect(version.errors[:event_type]).not_to be_empty
|
||||
end
|
||||
end
|
||||
|
||||
it 'prevents NicoTagVersion destroy' do
|
||||
version = NicoTagVersion.create!(
|
||||
external_tag: nico_tag,
|
||||
version_no: 1,
|
||||
event_type: 'create',
|
||||
name: "nico:#{ nico_tag.name }",
|
||||
linked_tags: '',
|
||||
linked_tag_ids: '',
|
||||
created_at: Time.current,
|
||||
created_by_user: nil
|
||||
)
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Counts masked search', type: :request do
|
||||
let!(:tag) { create(:tag, primary_name: '秘密のタグ') }
|
||||
let(:actor) { create(:user, :member) }
|
||||
let(:admin) { create(:user, :admin) }
|
||||
let(:base) do
|
||||
{ 'resource' => 'tags', 'definition' => 'history', 'mode' => 'summary',
|
||||
'locale' => 'ja', 'execution_at' => '2025-01-01T00:00:00Z',
|
||||
'timezone' => 'UTC', 'rows' => '', 'cols' => '', 'sort' => '' }
|
||||
end
|
||||
|
||||
before do
|
||||
[1, 2].each do |number|
|
||||
TagVersion.create!(tag:, version_no: number,
|
||||
event_type: number == 1 ? 'create' : 'discard', name: '秘密のタグ',
|
||||
category: 'general', aliases: '秘密の別名', parent_tag_ids: '',
|
||||
names: { ja: { name: '秘密のタグ', script_code: 'Jpan', auto_generated: false,
|
||||
aliases: [{ name: '秘密の別名', script_code: 'Jpan' }] } },
|
||||
created_by_user: actor, created_at: Time.utc(2023, number))
|
||||
end
|
||||
end
|
||||
|
||||
def query(extra = {})
|
||||
get '/counts/query', params: base.merge(extra)
|
||||
expect(response).to have_http_status(:ok), extra.inspect
|
||||
response.parsed_body
|
||||
end
|
||||
|
||||
def predicate(field, operator, value, index = 0)
|
||||
prefix = "conditions.#{ index }"
|
||||
result = { 'conditions' => '', "#{ prefix }.field" => field,
|
||||
"#{ prefix }.operator" => operator }
|
||||
if ['in', 'any', 'all', 'none', 'not_in'].include?(operator)
|
||||
result.merge!("#{ prefix }.values" => '1', 'condition_values' => '',
|
||||
"condition_values.#{ index }.condition" => index.to_s,
|
||||
"condition_values.#{ index }.value_type" => 'string',
|
||||
"condition_values.#{ index }.value" => value)
|
||||
else
|
||||
result["#{ prefix }.value"] = value
|
||||
end
|
||||
result
|
||||
end
|
||||
|
||||
it 'rejects every name and alias predicate on private history' do
|
||||
['name', 'name_ja', 'names.name', 'aliases.name', 'names.aliases.name'].each do |field|
|
||||
secret = field.include?('aliases') ? '秘密の別名' : '秘密のタグ'
|
||||
multiple = !['name', 'name_ja'].include?(field)
|
||||
operators = multiple ? ['any', 'all', 'none'] : ['in', 'not_in']
|
||||
['eq', 'ne', 'like', *operators].each do |operator|
|
||||
[secret, '********', '%', ''].each do |value|
|
||||
expected = (value == '' && ['eq', 'ne', 'like'].include?(operator)) ? 2 : 0
|
||||
expect(query(predicate(field, operator, value))['count']).to eq(expected)
|
||||
end
|
||||
end
|
||||
blank = predicate(field, 'eq', '').merge('conditions.0.blank' => '1')
|
||||
expect(query(blank)['count']).to eq(0)
|
||||
end
|
||||
end
|
||||
|
||||
it 'ignores missing and empty scalar values unless blank is checked' do
|
||||
['name', 'name_ja', 'names.name', 'aliases.name'].each do |field|
|
||||
['eq', 'ne', 'like'].each do |operator|
|
||||
condition = predicate(field, operator, '')
|
||||
[condition, condition.except('conditions.0.value')].each do |empty|
|
||||
expect(query(empty)['count']).to eq(2)
|
||||
expect(query(empty.merge('conditions.0.blank' => '0'))['count']).to eq(2)
|
||||
expect(query(empty.merge('conditions.0.blank' => '1'))['count']).to eq(0)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
it 'keeps details totals unchanged for inactive private predicates' do
|
||||
settings = predicate('name', 'ne', '').merge('mode' => 'details')
|
||||
result = query(settings)
|
||||
expect(result['count']).to eq(0)
|
||||
expect(result['versions']).to be_empty
|
||||
expect(result['output_count']).to eq(2)
|
||||
active = query(settings.merge('conditions.0.blank' => '1'))
|
||||
expect(active['output_count']).to eq(0)
|
||||
end
|
||||
|
||||
it 'ignores missing sets but preserves explicit empty set semantics' do
|
||||
{ 'name' => ['in', 'not_in'], 'aliases.name' => ['any', 'all', 'none'] }.each do
|
||||
|field, operators|
|
||||
operators.each do |operator|
|
||||
condition = { 'conditions' => '', 'conditions.0.field' => field,
|
||||
'conditions.0.operator' => operator }
|
||||
expect(query(condition)['count']).to eq(2)
|
||||
empty = condition.merge('conditions.0.values' => '1', 'condition_values' => '')
|
||||
expected = ['in', 'any'].include?(operator) ? 0 : 2
|
||||
expect(query(empty)['count']).to eq(expected)
|
||||
expect(query(condition.merge('conditions.0.blank' => '1'))['count']).to eq(0)
|
||||
# An explicit empty string element is a real value, not an unspecified set.
|
||||
expect(query(predicate(field, operator, ''))['count']).to eq(0)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
it 'ignores empty private predicates regardless of their position' do
|
||||
first = predicate('name', 'ne', '')
|
||||
.merge(predicate('resource_id', 'eq', tag.id.to_s, 1))
|
||||
last = predicate('resource_id', 'eq', tag.id.to_s)
|
||||
.merge(predicate('aliases.name', 'like', '', 1))
|
||||
expect(query(first)['count']).to eq(2)
|
||||
expect(query(last)['count']).to eq(2)
|
||||
active = predicate('name', 'ne', 'unrelated')
|
||||
.merge(predicate('aliases.name', 'like', '', 1))
|
||||
expect(query(active)['count']).to eq(0)
|
||||
end
|
||||
|
||||
it 'searches public literal markers and treats axis predicates as explicit' do
|
||||
public_tag = create(:tag, primary_name: '********')
|
||||
TagVersion.create!(tag: public_tag, version_no: 1, event_type: 'create',
|
||||
name: '********', category: 'general', aliases: '', parent_tag_ids: '',
|
||||
names: { ja: { name: '********', script_code: 'Jpan', auto_generated: false,
|
||||
aliases: [] } }, created_at: Time.utc(2023))
|
||||
expect(query['count']).to eq(3)
|
||||
['eq', 'ne', 'like', 'in', 'not_in'].each do |operator|
|
||||
expected = ['ne', 'not_in'].include?(operator) ? 0 : 1
|
||||
expect(query(predicate('name', operator, '********'))['count']).to eq(expected)
|
||||
end
|
||||
blank = predicate('aliases.name', 'eq', '').merge('conditions.0.blank' => '1')
|
||||
expect(query(blank)['count']).to eq(1)
|
||||
['********', '', nil].each do |value|
|
||||
condition = { 'conditions' => '', 'conditions.0.field' => 'name',
|
||||
'conditions.0.operator' => 'axis_eq',
|
||||
'conditions.0.value_type' => value.nil? ? 'null' : 'string' }
|
||||
condition['conditions.0.value'] = value unless value.nil?
|
||||
expected = value == '********' ? 1 : 0
|
||||
expect(query(condition)['count']).to eq(expected)
|
||||
drill = { 'drill' => '', 'drill.0.field' => 'name',
|
||||
'drill.0.value_type' => value.nil? ? 'null' : 'string' }
|
||||
drill['drill.0.value'] = value unless value.nil?
|
||||
expect(query(drill)['count']).to eq(expected)
|
||||
end
|
||||
end
|
||||
|
||||
it 'keeps public identity and timeline predicates searchable' do
|
||||
expect(query['count']).to eq(2)
|
||||
values = { 'resource_id' => tag.id.to_s, 'version_no' => '2',
|
||||
'event_type' => 'discard', 'user' => actor.id.to_s,
|
||||
'created_at' => '2023-02-01T00:00:00Z' }
|
||||
values.each do |field, value|
|
||||
expected = ['resource_id', 'user'].include?(field) ? 2 : 1
|
||||
expect(query(predicate(field, 'eq', value))['count']).to eq(expected)
|
||||
end
|
||||
condition = predicate('name', 'ne', '秘密のタグ')
|
||||
.merge(predicate('event_type', 'eq', 'discard', 1))
|
||||
expect(query(condition)['count']).to eq(0)
|
||||
expect(query(predicate('category', 'eq', '********'))['count']).to eq(0)
|
||||
expect(query(predicate('category', 'eq', 'general'))['count']).to eq(0)
|
||||
end
|
||||
|
||||
it 'keeps public predicates available after combining multiple conditions' do
|
||||
condition = predicate('resource_id', 'eq', tag.id.to_s)
|
||||
.merge(predicate('event_type', 'eq', 'discard', 1))
|
||||
expect(query(condition)['count']).to eq(1)
|
||||
condition.merge!(predicate('name', 'ne', 'unrelated', 2))
|
||||
expect(query(condition)['count']).to eq(0)
|
||||
end
|
||||
|
||||
it 'keeps real name searches for administrators and excludes private details' do
|
||||
condition = predicate('name', 'eq', '秘密のタグ')
|
||||
details = query(condition.merge('mode' => 'details'))
|
||||
expect(details['versions']).to be_empty
|
||||
expect(details['output_count']).to eq(0)
|
||||
masked = query(predicate('name', 'eq', '********').merge('mode' => 'details'))
|
||||
expect(masked['versions']).to be_empty
|
||||
expect(masked['output_count']).to eq(0)
|
||||
sign_in_as(admin)
|
||||
expect(query(condition)['count']).to eq(2)
|
||||
expect(query(condition.merge('mode' => 'details'))['versions'].length).to eq(2)
|
||||
end
|
||||
|
||||
it 'rejects private names reached through a post tag snapshot' do
|
||||
post = create(:post)
|
||||
PostVersion.create!(post:, version_no: 1, event_type: 'create', title: 'post',
|
||||
url: post.url, tags: '', parent_post_ids: '',
|
||||
tags_json: [{ tag_id: tag.id, version_no: 1, name: '秘密のタグ',
|
||||
category: 'general', sections: [] }],
|
||||
created_at: Time.utc(2024))
|
||||
['tags.name', 'tags.names.name', 'tags.name_ja'].each do |field|
|
||||
['eq', 'ne', 'like', 'any', 'all', 'none'].each do |operator|
|
||||
['秘密のタグ', '********', '%'].each do |value|
|
||||
settings = predicate(field, operator, value).merge('resource' => 'posts')
|
||||
expect(query(settings)['count']).to eq(0)
|
||||
end
|
||||
end
|
||||
blank = predicate(field, 'eq', '').merge('resource' => 'posts',
|
||||
'conditions.0.blank' => '1')
|
||||
expect(query(blank)['count']).to eq(0)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -19,7 +19,7 @@ RSpec.describe 'Counts table API', type: :request do
|
||||
names: { ja: { name: 'alpha', script_code: 'Jpan', auto_generated: false, aliases: [] } },
|
||||
created_at: Time.utc(2023))
|
||||
NicoTagVersion.create!(external_tag: external, version_no: 1,
|
||||
event_type: 'create', name: 'beta', linked_tags: '', created_at: Time.utc(2023))
|
||||
event_type: 'create', linked_tag_ids: '', created_at: Time.utc(2023))
|
||||
snapshots = [
|
||||
{ tag_id: tag.id, version_no: 1, name: 'alpha', category: 'general', sections: [] },
|
||||
{ external_tag_id: external.id }]
|
||||
@@ -50,6 +50,115 @@ RSpec.describe 'Counts table API', type: :request do
|
||||
|
||||
def versions(result) = result.fetch('versions').map { _1.fetch('version_no') }
|
||||
|
||||
context 'nico link ID snapshots through the V1 field name' do
|
||||
let(:base) do
|
||||
super().merge(resource: 'nico_tags')
|
||||
end
|
||||
|
||||
before do
|
||||
NicoTagVersion.create!(external_tag: external, version_no: 2,
|
||||
event_type: 'update', linked_tag_ids: tag.id.to_s,
|
||||
created_at: Time.utc(2024, 1, 1))
|
||||
NicoTagVersion.create!(external_tag: external, version_no: 3,
|
||||
event_type: 'update', linked_tag_ids: '',
|
||||
created_at: Time.utc(2024, 2, 1))
|
||||
end
|
||||
|
||||
it 'returns ID values under linked_tags without exposing the physical column key' do
|
||||
result = query
|
||||
expect(versions(result)).to eq([1, 2, 3])
|
||||
expect(result['versions'].map { _1.fetch('linked_tags') }).to eq(['', tag.id.to_s, ''])
|
||||
result['versions'].each do |row|
|
||||
expect(row).not_to have_key('linked_tag_ids')
|
||||
expect(row.fetch('name')).to eq('nico:beta')
|
||||
end
|
||||
end
|
||||
|
||||
it 'preloads external names separately for aliased link-change details' do
|
||||
statements = []
|
||||
listener = lambda do |_name, _start, _finish, _id, payload|
|
||||
statements << payload[:sql]
|
||||
end
|
||||
ActiveSupport::Notifications.subscribed(listener, 'sql.active_record') do
|
||||
result = query(axis('changed_field', 'linked_tags'))
|
||||
expect(versions(result)).to eq([2, 3])
|
||||
expect(result.fetch('versions').map { _1.fetch('name') })
|
||||
.to eq(['nico:beta', 'nico:beta'])
|
||||
end
|
||||
|
||||
details = statements.find { _1.include?('AS counts_latest') }
|
||||
expect(details).not_to be_nil
|
||||
expect(details).not_to include('JOIN `external_tags`')
|
||||
names = statements.select { _1.match?(/SELECT .* FROM `external_tags`/) }
|
||||
expect(names.length).to eq(1)
|
||||
end
|
||||
|
||||
it 'advertises the legacy field, label and default columns only' do
|
||||
get '/counts/definitions/nico_tags', params: { locale: 'ja' }
|
||||
expect(response).to have_http_status(:ok)
|
||||
body = response.parsed_body
|
||||
['fields', 'filter_fields', 'output_fields'].each do |collection|
|
||||
keys = body.fetch(collection).map { _1.fetch('key') }
|
||||
expect(keys).to include('linked_tags')
|
||||
expect(keys).not_to include('linked_tag_ids')
|
||||
end
|
||||
field = body.fetch('output_fields').find { _1['key'] == 'linked_tags' }
|
||||
expect(field.fetch('label')).to eq('counts:attributes.linked_tags')
|
||||
body.fetch('axes').each_value do |keys|
|
||||
expect(keys).to include('linked_tags')
|
||||
expect(keys).not_to include('linked_tag_ids')
|
||||
end
|
||||
body.fetch('defaults').select { _1['mode'] == 'details' }.each do |entry|
|
||||
settings = entry.fetch('params').to_h { [_1.fetch('key'), _1.fetch('value')] }
|
||||
expect(settings.fetch('columns').split).to include('name', 'linked_tags')
|
||||
expect(settings.fetch('columns').split).not_to include('linked_tag_ids')
|
||||
end
|
||||
end
|
||||
|
||||
it 'filters IDs and legacy drills and compares link changes through linked_tags' do
|
||||
predicate = { conditions: '', 'conditions.0.field' => 'linked_tags',
|
||||
'conditions.0.operator' => 'eq', 'conditions.0.value' => tag.id.to_s }
|
||||
expect(versions(query(predicate))).to eq([2])
|
||||
expect(versions(query(axis('linked_tags', tag.id.to_s, root: 'drill')))).to eq([2])
|
||||
expect(versions(query(axis('changed_field', 'linked_tags')))).to eq([2, 3])
|
||||
expect(versions(query(predicate.merge('conditions.0.value' => 'alpha')))).to be_empty
|
||||
end
|
||||
|
||||
it 'sorts ID snapshots through the public field in both directions' do
|
||||
expect(versions(query(sort: 'linked_tags:asc version_no:asc'))).to eq([1, 3, 2])
|
||||
expect(versions(query(sort: 'linked_tags:desc version_no:asc'))).to eq([2, 1, 3])
|
||||
end
|
||||
|
||||
it 'restores historical link IDs before applying a latest snapshot filter' do
|
||||
predicate = { definition: 'latest', at: '2024-01-15T00:00:00Z',
|
||||
conditions: '', 'conditions.0.field' => 'linked_tags',
|
||||
'conditions.0.operator' => 'eq', 'conditions.0.value' => tag.id.to_s }
|
||||
expect(versions(query(predicate))).to eq([2])
|
||||
expect(versions(query(predicate.except(:at)))).to be_empty
|
||||
end
|
||||
|
||||
it 'groups by linked_tags ID values and accepts the resulting drill condition' do
|
||||
sign_in_as(create(:user, :admin))
|
||||
result = query(mode: 'summary', rows: 'linked_tags', cols: '', sort: '')
|
||||
expect(result.fetch('cells')).to match_array([
|
||||
{ 'values' => [''], 'count' => 2 },
|
||||
{ 'values' => [tag.id.to_s], 'count' => 1 }])
|
||||
expect(versions(query(axis('linked_tags', tag.id.to_s)))).to eq([2])
|
||||
end
|
||||
|
||||
it 'rejects the physical column as a public filter, axis, sort or change field' do
|
||||
sign_in_as(create(:user, :admin))
|
||||
[
|
||||
axis('linked_tag_ids', tag.id.to_s),
|
||||
axis('changed_field', 'linked_tag_ids'),
|
||||
{ mode: 'summary', rows: 'linked_tag_ids', cols: '', sort: '' },
|
||||
{ sort: 'linked_tag_ids:asc' }].each do |invalid|
|
||||
get '/counts/query', params: base.merge(invalid)
|
||||
expect(response).to have_http_status(:bad_request), invalid.inspect
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
it 'identifies a totals timeout without exposing SQL in the error response' do
|
||||
allow_any_instance_of(CountsPivotTotals).to receive(:call)
|
||||
.and_raise(CountsPivotTotals::TimedOut, 'private SQL')
|
||||
@@ -223,11 +332,10 @@ RSpec.describe 'Counts table API', type: :request do
|
||||
expect(versions(query(tags: 'renamed-alpha'))).to eq([1, 2, 4])
|
||||
end
|
||||
|
||||
it 'resolves external names from current registrations too' do
|
||||
external.update!(name: 'renamed-beta')
|
||||
expect(versions(query(tags: 'nico:renamed-beta'))).to eq([1])
|
||||
expect(versions(query(tags: 'nico:beta'))).to eq([])
|
||||
expect(versions(query(tags: 'not:nico:renamed-beta'))).to eq([2, 3, 4])
|
||||
it 'resolves immutable external names across historical snapshots' do
|
||||
expect(versions(query(tags: 'nico:beta'))).to eq([1])
|
||||
expect(versions(query(tags: 'nico:renamed-beta'))).to eq([])
|
||||
expect(versions(query(tags: 'not:nico:beta'))).to eq([2, 3, 4])
|
||||
end
|
||||
end
|
||||
|
||||
@@ -354,15 +462,25 @@ RSpec.describe 'Counts table API', type: :request do
|
||||
expect(result['cells'].sum { _1['count'] }).to eq(4)
|
||||
expect(response.body).not_to include('hidden title')
|
||||
masked = query(mode: 'summary', rows: 'resource_id', cols: '')
|
||||
expect(masked['cells'].map { _1['values'] }).to eq([[CountsAxes::MASKED_VALUE]])
|
||||
expect(masked['cells'].map { _1['values'] }).to eq([[record.id]])
|
||||
expect(masked['cells'].first['count']).to eq(4)
|
||||
expect(query['versions']).to be_empty
|
||||
end
|
||||
|
||||
it 'rejects private summary predicates instead of exposing a count oracle' do
|
||||
get '/counts/query', params: base.merge(mode: 'summary', resource_id: record.id.to_s)
|
||||
expect(response).to have_http_status(:forbidden)
|
||||
expect(json).to eq('error' => 'private_condition')
|
||||
it 'allows public identity predicates without exposing private snapshot fields' do
|
||||
PostVersion.create!(post: record, version_no: 4, event_type: 'discard',
|
||||
url: record.url, title: 'hidden title', tags: '', tags_json: [],
|
||||
parent_post_ids: '', created_at: Time.utc(2024, 3))
|
||||
|
||||
get '/counts/query', params: base.merge(mode: 'summary',
|
||||
resource_id: record.id.to_s, rows: 'resource_id', cols: 'title')
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json['count']).to eq(4)
|
||||
expect(json['cells'].sum { _1['count'] }).to eq(4)
|
||||
expect(json['cells'].map { _1['values'].last })
|
||||
.to include(CountsAxes::MASKED_VALUE)
|
||||
expect(response.body).not_to include('hidden title')
|
||||
end
|
||||
|
||||
it 'reports the combined axis limit without replacing it with a generic 400' do
|
||||
|
||||
@@ -20,7 +20,7 @@ RSpec.describe 'error responses', type: :request do
|
||||
|
||||
patch "/tags/#{ tag.id }", params: { category: 'nico' }
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response).to have_http_status(:unprocessable_content)
|
||||
expect(json).to include(
|
||||
'type' => 'validation_error',
|
||||
'message' => '入力内容を確認してください.',
|
||||
@@ -37,7 +37,7 @@ RSpec.describe 'error responses', type: :request do
|
||||
|
||||
put "/users/#{ user.id }", params: { name: 'a' * 256 }
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response).to have_http_status(:unprocessable_content)
|
||||
expect(json).to include(
|
||||
'type' => 'validation_error',
|
||||
'message' => '入力内容を確認してください.')
|
||||
|
||||
@@ -38,12 +38,9 @@ RSpec.describe 'NicoTags', type: :request do
|
||||
end
|
||||
|
||||
it 'filters by nico tag name, linked tag name, and link status' do
|
||||
linked = create(:external_tag)
|
||||
linked.update!(name: 'search_linked')
|
||||
unlinked = create(:external_tag)
|
||||
unlinked.update!(name: 'search_unlinked')
|
||||
other = create(:external_tag)
|
||||
other.update!(name: 'other')
|
||||
linked = create(:external_tag, name: 'search_linked')
|
||||
unlinked = create(:external_tag, name: 'search_unlinked')
|
||||
other = create(:external_tag, name: 'other')
|
||||
destination = create(:tag, category: :general)
|
||||
primary_tag_name_for(destination, 'ja').update!(name: 'destination_search')
|
||||
NicoTagRelation.create!(nico_tag: linked, tag: destination)
|
||||
@@ -80,11 +77,9 @@ RSpec.describe 'NicoTags', type: :request do
|
||||
end
|
||||
|
||||
it 'sorts by name and timestamps' do
|
||||
older = create(:external_tag)
|
||||
older.update!(name: 'ordered_a')
|
||||
older = create(:external_tag, name: 'ordered_a')
|
||||
older.update_columns(created_at: 2.days.ago)
|
||||
newer = create(:external_tag)
|
||||
newer.update!(name: 'ordered_b')
|
||||
newer = create(:external_tag, name: 'ordered_b')
|
||||
newer.update_columns(created_at: 1.day.ago)
|
||||
older_post_tag =
|
||||
PostExternalTag.create!(post: create(:post), external_tag: older)
|
||||
@@ -189,9 +184,8 @@ RSpec.describe 'NicoTags', type: :request do
|
||||
|
||||
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.linked_tag_ids.split.map(&:to_i))
|
||||
.to match_array([linked_a.id, linked_b.id])
|
||||
expect(versions.last.created_by_user_id).to eq(admin.id)
|
||||
end
|
||||
|
||||
@@ -210,7 +204,7 @@ RSpec.describe 'NicoTags', type: :request do
|
||||
expect(json).to eq([])
|
||||
expect(nico_tag.reload.linked_tags).to be_empty
|
||||
expect(nico_tag.nico_tag_versions.order(:version_no).last)
|
||||
.to have_attributes(linked_tags: '', created_by_user: member)
|
||||
.to have_attributes(linked_tag_ids: '', created_by_user: member)
|
||||
end
|
||||
|
||||
it 'returns the tags field error when a nico tag is specified directly' do
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Post history privacy', type: :request do
|
||||
let(:origin) { Time.utc(2020, 1, 1) }
|
||||
let!(:post_record) do
|
||||
create(:post, title: 'current', created_at: origin, updated_at: origin)
|
||||
end
|
||||
let!(:other_post) do
|
||||
create(:post, created_at: origin, updated_at: origin + 3.days + 1.hour)
|
||||
end
|
||||
let!(:tag) { create(:tag, primary_name: 'private-history-tag') }
|
||||
let!(:external) { create(:external_tag, name: 'external-history-tag') }
|
||||
|
||||
before do
|
||||
['create', 'discard'].each_with_index do |event, index|
|
||||
TagVersion.create!(tag:, version_no: index + 1, event_type: event,
|
||||
name: 'private-history-tag', category: 'general', aliases: '', parent_tag_ids: '',
|
||||
names: { ja: { name: 'private-history-tag', script_code: 'Jpan',
|
||||
auto_generated: false, aliases: [] } },
|
||||
created_at: origin + index.hours)
|
||||
end
|
||||
NicoTagVersion.create!(external_tag: external, version_no: 1, event_type: 'create',
|
||||
linked_tag_ids: tag.id.to_s,
|
||||
created_at: origin)
|
||||
['create', 'discard', 'restore', 'update'].each_with_index do |event, index|
|
||||
snapshots =
|
||||
if index < 2
|
||||
[{ tag_id: tag.id, version_no: 1, name: 'private-history-tag', sections: [] }]
|
||||
elsif index == 3
|
||||
[{ external_tag_id: external.id }]
|
||||
else
|
||||
[]
|
||||
end
|
||||
PostVersion.create!(post: post_record, version_no: index + 1, event_type: event,
|
||||
title: "version #{ index + 1 }", url: post_record.url, tags: '',
|
||||
tags_json: snapshots, parent_post_ids: '', created_at: origin + (index + 1).days)
|
||||
end
|
||||
end
|
||||
|
||||
it 'evaluates each reference condition against the restored snapshot alone' do
|
||||
HistoryAccess.set(user: nil) do
|
||||
restored = PostVersion.where(post_id: post_record.id, version_no: 3)
|
||||
HistoryAccess.reference_sql('post_versions', 'post_versions').each do |condition|
|
||||
expect(restored.where(Arel.sql(condition)).pluck(:version_no)).to eq([3])
|
||||
end
|
||||
expect(HistoryAccess.visible(PostVersion.where(post_id: post_record.id))
|
||||
.pluck(:version_no)).to eq([3])
|
||||
end
|
||||
end
|
||||
|
||||
it 'keeps every version through a later discard private after another restore' do
|
||||
['discard', 'restore'].each_with_index do |event, index|
|
||||
PostVersion.create!(post: post_record, version_no: index + 5, event_type: event,
|
||||
title: 'later snapshot', url: post_record.url, tags: '', tags_json: [],
|
||||
parent_post_ids: '', created_at: origin + (index + 5).days)
|
||||
end
|
||||
|
||||
get '/posts/versions', params: { post: post_record.id }
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(response.parsed_body.fetch('versions').pluck('version_no')).to eq([6])
|
||||
expect(response.parsed_body.fetch('count')).to eq(1)
|
||||
expect(response.parsed_body.fetch('versions').first.fetch('title').fetch('prev')).to be_nil
|
||||
end
|
||||
|
||||
['anonymous', 'member', 'admin'].each do |role|
|
||||
context "as #{ role }" do
|
||||
before do
|
||||
sign_in_as(create(:user, role.to_sym)) unless role == 'anonymous'
|
||||
end
|
||||
|
||||
it 'uses only authorised history timestamps for display' do
|
||||
get '/posts'
|
||||
expect(response).to have_http_status(:ok)
|
||||
row = response.parsed_body.fetch('posts').find { _1.fetch('id') == post_record.id }
|
||||
expected = origin + (role == 'admin' ? 4 : 3).days
|
||||
expect(Time.zone.parse(row.fetch('updated_at'))).to eq(expected)
|
||||
end
|
||||
|
||||
it 'uses the same visibility for date bounds and count' do
|
||||
get '/posts', params: { updated_from: (origin + 4.days).iso8601,
|
||||
updated_to: (origin + 4.days).iso8601 }
|
||||
expect(response).to have_http_status(:ok)
|
||||
ids = response.parsed_body.fetch('posts').pluck('id')
|
||||
expect(ids).to eq(role == 'admin' ? [post_record.id] : [])
|
||||
expect(response.parsed_body.fetch('count')).to eq(ids.length)
|
||||
end
|
||||
|
||||
it 'sorts using authorised timestamps in both directions' do
|
||||
['asc', 'desc'].each do |direction|
|
||||
get '/posts', params: { order: "updated_at:#{ direction }" }
|
||||
expect(response).to have_http_status(:ok)
|
||||
expected = role == 'admin' ? [other_post.id, post_record.id] :
|
||||
[post_record.id, other_post.id]
|
||||
expected.reverse! if direction == 'desc'
|
||||
expect(response.parsed_body.fetch('posts').pluck('id')).to eq(expected)
|
||||
end
|
||||
end
|
||||
|
||||
it 'applies authorised timestamp ordering before pagination' do
|
||||
get '/posts', params: { order: 'updated_at:desc', limit: 1 }
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expected = role == 'admin' ? post_record.id : other_post.id
|
||||
expect(response.parsed_body.fetch('posts').pluck('id')).to eq([expected])
|
||||
expect(response.parsed_body.fetch('count')).to eq(2)
|
||||
end
|
||||
|
||||
it 'counts only visible versions and masks a private previous version' do
|
||||
get '/posts/versions', params: { post: post_record.id }
|
||||
expect(response).to have_http_status(:ok)
|
||||
body = response.parsed_body
|
||||
expected = role == 'admin' ? [4, 3, 2, 1] : [3]
|
||||
expect(body.fetch('count')).to eq(expected.length)
|
||||
expect(body.fetch('versions').pluck('version_no')).to eq(expected)
|
||||
restored = body.fetch('versions').find { _1.fetch('version_no') == 3 }
|
||||
expect(restored.fetch('title').fetch('prev')).to eq(role == 'admin' ? 'version 2' : nil)
|
||||
end
|
||||
|
||||
it 'uses only an authorised previous version for tag filtering' do
|
||||
get '/posts/versions', params: { post: post_record.id, tag: tag.id }
|
||||
expect(response).to have_http_status(:ok)
|
||||
expected = role == 'admin' ? [3, 2, 1] : []
|
||||
expect(response.parsed_body.fetch('versions').pluck('version_no')).to eq(expected)
|
||||
expect(response.parsed_body.fetch('count')).to eq(expected.length)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -629,6 +629,37 @@ RSpec.describe 'Posts API', type: :request do
|
||||
create(:post_tag, post: plain_post, tag:, created_at: t3)
|
||||
end
|
||||
|
||||
it 'displays history time after pagination without joining history to posts or count' do
|
||||
queries = []
|
||||
subscriber = ->(event) { queries << event.payload.fetch(:sql) }
|
||||
ActiveSupport::Notifications.subscribed(subscriber, 'sql.active_record') do
|
||||
get '/posts', params: { url: '/version-time/', order: 'url:asc', limit: 1 }
|
||||
end
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json.fetch('posts').pluck('id')).to eq([history_post.id])
|
||||
expect(Time.zone.parse(json.fetch('posts').first.fetch('updated_at'))).to eq(t2)
|
||||
expect(json.fetch('count')).to eq(3)
|
||||
post_queries = queries.select { _1.include?('FROM `posts`') }
|
||||
expect(post_queries).not_to be_empty
|
||||
expect(post_queries.grep(/post_versions/)).to be_empty
|
||||
end
|
||||
|
||||
it 'omits the history aggregate from count when only sorting by update time' do
|
||||
queries = []
|
||||
subscriber = ->(event) { queries << event.payload.fetch(:sql) }
|
||||
ActiveSupport::Notifications.subscribed(subscriber, 'sql.active_record') do
|
||||
get '/posts', params: { url: '/version-time/', order: 'updated_at:desc', limit: 1 }
|
||||
end
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json.fetch('posts').pluck('id')).to eq([newer_post.id])
|
||||
expect(json.fetch('count')).to eq(3)
|
||||
count_queries = queries.select { _1.include?('FROM `posts`') && _1.match?(/COUNT\(/i) }
|
||||
expect(count_queries).not_to be_empty
|
||||
expect(count_queries.grep(/post_versions/)).to be_empty
|
||||
end
|
||||
|
||||
['asc', 'desc'].each do |direction|
|
||||
it "sorts by the later of post update and latest version time (#{ direction })" do
|
||||
get '/posts', params: { url: '/version-time/', order: "updated_at:#{ direction }" }
|
||||
@@ -658,6 +689,16 @@ RSpec.describe 'Posts API', type: :request do
|
||||
expect(json.fetch('posts').map { |item| item.fetch('id') }).to eq([history_post.id])
|
||||
expect(json.fetch('count')).to eq(1)
|
||||
end
|
||||
|
||||
it 'counts every timestamp match before pagination' do
|
||||
get '/posts', params: { url: '/version-time/', order: 'updated_at:asc',
|
||||
updated_from: t1.iso8601, updated_to: t3.iso8601,
|
||||
limit: 1, page: 2 }
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json.fetch('posts').pluck('id')).to eq([history_post.id])
|
||||
expect(json.fetch('count')).to eq(3)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when original_created_from/original_created_to are provided' do
|
||||
|
||||
@@ -22,6 +22,55 @@ RSpec.describe 'Counts export contracts' do
|
||||
.and_return(@counts_export_dir)
|
||||
end
|
||||
|
||||
context 'nico link IDs with the V1 export field name' do
|
||||
let(:tag) { create(:tag, primary_name: 'linked export tag') }
|
||||
let(:external) { create(:external_tag, name: 'external export tag') }
|
||||
let(:payload) do
|
||||
super().merge('resource' => 'nico_tags', 'columns' => 'linked_tags',
|
||||
'headers' => '["linked_tags"]')
|
||||
end
|
||||
|
||||
before do
|
||||
NicoTagVersion.create!(external_tag: external, version_no: 1,
|
||||
event_type: 'create', linked_tag_ids: '', created_at: Time.utc(2023))
|
||||
NicoTagVersion.create!(external_tag: external, version_no: 2,
|
||||
event_type: 'update', linked_tag_ids: tag.id.to_s, created_at: Time.utc(2024))
|
||||
end
|
||||
|
||||
it 'exports recorded IDs with the legacy JSON key and metadata' do
|
||||
result = JSON.parse(CountsTableExport.new(payload).body.to_a.join)
|
||||
expect(result.fetch('data')).to eq([
|
||||
{ 'linked_tags' => '' }, { 'linked_tags' => tag.id.to_s }])
|
||||
expect(result.fetch('metadata').fetch('columns')).to eq(['linked_tags'])
|
||||
keys = result.fetch('metadata').fetch('fields').map { _1.fetch('key') }
|
||||
expect(keys).to include('linked_tags')
|
||||
expect(keys).not_to include('linked_tag_ids')
|
||||
end
|
||||
|
||||
it 'exports the selected legacy CSV header and ID values' do
|
||||
body = CountsTableExport.new(payload.merge('format' => 'csv')).body.to_a.join
|
||||
expect(CSV.parse(body.delete_prefix("\uFEFF")))
|
||||
.to eq([['linked_tags'], [''], [tag.id.to_s]])
|
||||
end
|
||||
|
||||
it 'keeps the legacy link column in default JSON exports' do
|
||||
options = payload.except('columns', 'headers')
|
||||
result = JSON.parse(CountsTableExport.new(options).body.to_a.join)
|
||||
expect(result.fetch('metadata').fetch('columns')).to include('linked_tags')
|
||||
result.fetch('data').each do |row|
|
||||
expect(row).to have_key('linked_tags')
|
||||
expect(row).not_to have_key('linked_tag_ids')
|
||||
end
|
||||
end
|
||||
|
||||
it 'rejects the physical column as a selected CSV or JSON field' do
|
||||
['csv', 'json'].each do |format|
|
||||
options = payload.merge('format' => format, 'columns' => 'linked_tag_ids')
|
||||
expect { CountsTableExport.new(options) }.to raise_error(ArgumentError)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'serialization after the authorised query' do
|
||||
let(:rows) { [{ title: "=formula\n\"quoted\"" }, { title: 'second' }] }
|
||||
let(:page) do
|
||||
@@ -221,9 +270,15 @@ RSpec.describe CountsExportDownload::ResponseBody do
|
||||
body = described_class.new(source) { |state| states << state; true }
|
||||
response = ActionDispatch::Response.new
|
||||
response.body = body
|
||||
deferred = response.stream.to_ary
|
||||
|
||||
_, _, rack_body = response.to_a
|
||||
|
||||
expect(states).to eq([])
|
||||
expect(deferred.to_a.join).to eq(payload)
|
||||
|
||||
chunks = []
|
||||
rack_body.each { |chunk| chunks << chunk }
|
||||
|
||||
expect(chunks.join).to eq(payload)
|
||||
expect(states.count('started')).to eq(1)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -69,27 +69,29 @@ RSpec.describe 'Counts masked pivot contracts' do
|
||||
end
|
||||
end
|
||||
|
||||
it 'preserves five tag occurrences after merging private tags and across pages' do
|
||||
it 'counts one opaque collection per private version across pages' do
|
||||
[nil, guest, member, admin].each do |user|
|
||||
page = result(user)
|
||||
expect(grand(page)).to eq(5)
|
||||
expect(page[:cells].sum { _1[:count] }).to eq(5)
|
||||
expected = user == admin ? 5 : 6
|
||||
expect(grand(page)).to eq(expected)
|
||||
expect(page[:cells].sum { _1[:count] }).to eq(expected)
|
||||
expect(page).not_to have_key(:excluded_count)
|
||||
paged = result(user, 'limit' => '1')
|
||||
expect(grand(paged)).to eq(5)
|
||||
expect(grand(paged)).to eq(expected)
|
||||
expect(result(user, 'limit' => '1', 'page' => '2')[:group_count])
|
||||
.to eq(page[:group_count])
|
||||
end
|
||||
public_page = result(nil)
|
||||
hidden = public_page[:cells].select { _1[:values].first == CountsAxes::MASKED_VALUE }
|
||||
expect(hidden.sum { _1[:count] }).to eq(3)
|
||||
expect(hidden.sum { _1[:count] }).to eq(4)
|
||||
expect(public_page[:axis_labels]['a0']).not_to have_key("internal:#{ tags.first.id }")
|
||||
expect(public_page.to_json).not_to include('hidden-title', 'private-discard')
|
||||
end
|
||||
|
||||
it 'retains distinct membership when tags are not a row axis' do
|
||||
[nil, admin].each do |user|
|
||||
expect(grand(result(user, 'rows' => 'event_type', 'cols' => 'tags'))).to eq(3)
|
||||
expect(grand(result(user, 'rows' => 'event_type', 'cols' => 'tags')))
|
||||
.to eq(user == admin ? 3 : 5)
|
||||
expect(result(user, 'rows' => '', 'cols' => '')[:cells].sole[:count]).to eq(5)
|
||||
details = result(user, 'mode' => 'details', 'rows' => '', 'cols' => '')
|
||||
expect(details[:count]).to eq(user == admin ? 5 : 1)
|
||||
@@ -121,14 +123,13 @@ RSpec.describe 'Counts masked pivot contracts' do
|
||||
it 'counts distinct changed records when tags are not additive row tuples' do
|
||||
[nil, admin].each do |user|
|
||||
page = result(user, 'rows' => 'changed_field', 'cols' => 'event_type')
|
||||
expect(grand(page)).to eq(2)
|
||||
expect(grand(page)).to eq(user == admin ? 2 : 4)
|
||||
if user.nil?
|
||||
expect(page[:cells].map { _1[:values].first }.uniq).to eq([CountsAxes::MASKED_VALUE])
|
||||
end
|
||||
# Changed discard snapshots have no tags: no full cell contributes here.
|
||||
empty = result(user, 'rows' => 'changed_field', 'cols' => 'tags')
|
||||
expect(empty[:cells]).to be_empty
|
||||
expect(grand(empty)).to eq(0)
|
||||
# Private empty collections contribute the same opaque tuple as populated ones.
|
||||
combined = result(user, 'rows' => 'changed_field', 'cols' => 'tags')
|
||||
expect(grand(combined)).to eq(user == admin ? 0 : 4)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -142,7 +143,7 @@ RSpec.describe 'Counts masked pivot contracts' do
|
||||
expect(page[:row_headers]).to include(hash_including(values: [nil], kind: 'data'))
|
||||
end
|
||||
|
||||
it 'sorts public numeric values numerically alongside a masked group' do
|
||||
it 'sorts public version numbers numerically including formerly hidden records' do
|
||||
post = PostVersion.order(:id).last.post
|
||||
[2, 10].each do |number|
|
||||
PostVersion.create!(post:, version_no: number, event_type: 'update',
|
||||
@@ -151,27 +152,196 @@ RSpec.describe 'Counts masked pivot contracts' do
|
||||
end
|
||||
page = result(nil, 'rows' => 'version_no', 'cols' => '')
|
||||
values = page[:row_headers].select { _1[:kind] == 'data' }.map { _1[:values].first }
|
||||
expect(values.reject { _1 == CountsAxes::MASKED_VALUE }.map(&:to_i)).to eq([1, 2, 10])
|
||||
expect(values.last).to eq(CountsAxes::MASKED_VALUE)
|
||||
expect(values.map(&:to_i)).to eq([1, 2, 10])
|
||||
end
|
||||
|
||||
it 'rejects private predicates and supplied measure coordinates before SQL labels escape' do
|
||||
probes = [{ 'resource_id' => PostVersion.first.post_id.to_s },
|
||||
{ 'tags' => 'A' },
|
||||
{ 'conditions' => '', 'conditions.0.field' => 'title', 'conditions.0.operator' => 'eq',
|
||||
'conditions.0.value' => 'hidden-title-0' },
|
||||
{ 'drill' => '', 'drill.0.field' => 'tags', 'drill.0.value_type' => 'string',
|
||||
'drill.0.value' => "internal:#{ tags.first.id }" },
|
||||
it 'keeps public identity predicates and excludes private attributes' do
|
||||
hidden_id = PostVersion.first.post_id.to_s
|
||||
base = { 'rows' => '', 'cols' => '' }
|
||||
condition = lambda do |field, operator, value|
|
||||
{ 'conditions' => '', 'conditions.0.field' => field,
|
||||
'conditions.0.operator' => operator, 'conditions.0.value' => value }
|
||||
end
|
||||
|
||||
expect(result(nil, base.merge('resource_id' => hidden_id))[:cells].sole[:count]).to eq(2)
|
||||
expect(result(nil, base.merge(condition.call('version_no', 'eq', '2')))[:cells]
|
||||
.sole[:count]).to eq(2)
|
||||
expect(result(nil, base.merge(condition.call('user', 'eq', member.id.to_s)))[:cells]
|
||||
.sole[:count]).to eq(5)
|
||||
expect(result(nil, base.merge(condition.call('title', 'eq', 'hidden-title-0')))[:cells]
|
||||
.sole[:count]).to eq(0)
|
||||
expect(result(nil, base.merge(condition.call('title', 'ne', 'hidden-title-0')))[:cells]
|
||||
.sole[:count]).to eq(1)
|
||||
expect(result(nil, base.merge(condition.call('title', 'eq', '********')))[:cells]
|
||||
.sole[:count]).to eq(0)
|
||||
expect(result(nil, base.merge(condition.call('video_ms', 'eq', '********')))[:cells]
|
||||
.sole[:count]).to eq(0)
|
||||
expect(result(nil, base.merge(condition.call('title', 'like', '%title%')))[:cells]
|
||||
.sole[:count]).to eq(1)
|
||||
expect(result(nil, base.merge(condition.call('title', 'like', '%')))[:cells]
|
||||
.sole[:count]).to eq(1)
|
||||
expect(result(nil, base.merge('tags' => 'C'))[:cells].sole[:count]).to eq(1)
|
||||
expect(result(nil, base.merge('tags' => 'not:C'))[:cells].sole[:count]).to eq(0)
|
||||
end
|
||||
|
||||
it 'retains public OR branches while rejecting private predicates on hidden rows' do
|
||||
HistoryAccess.set(user: nil) do
|
||||
population = CountsPopulation.new(params.merge('rows' => '', 'cols' => ''),
|
||||
authorisation_flg: false)
|
||||
condition = CountsCondition.new(population)
|
||||
public_scope = condition.apply(population.scope,
|
||||
{ 'field' => 'event_type', 'operator' => 'eq', 'value' => 'discard' })
|
||||
private_scope = condition.apply(population.scope,
|
||||
{ 'field' => 'title', 'operator' => 'ne', 'value' => 'unrelated' })
|
||||
masked_scope = condition.apply(population.scope,
|
||||
{ 'field' => 'title', 'operator' => 'eq', 'value' => '********' })
|
||||
expect(public_scope.except(:select).count).to eq(2)
|
||||
expect(public_scope.or(private_scope).except(:select).count).to eq(3)
|
||||
expect(private_scope.or(public_scope).except(:select).count).to eq(3)
|
||||
expect(public_scope.or(masked_scope).except(:select).count).to eq(2)
|
||||
expect(public_scope.merge(private_scope).except(:select).count).to eq(0)
|
||||
end
|
||||
end
|
||||
|
||||
it 'excludes private collections from positive, negative, AND and OR tag searches' do
|
||||
settings = { 'rows' => '', 'cols' => '' }
|
||||
{ 'C' => 1, 'not:C' => 0, 'not:unknown' => 1, '********' => 0,
|
||||
'not:********' => 1, 'not:C C' => 0, 'not:C type:or C' => 1 }.each do |value, count|
|
||||
expect(result(nil, settings.merge('tags' => value))[:cells].sole[:count]).to eq(count)
|
||||
end
|
||||
end
|
||||
|
||||
it 'ignores empty numeric and interval bounds without restricting private history' do
|
||||
settings = { 'rows' => '', 'cols' => '', 'conditions' => '' }
|
||||
{ 'video_ms' => ['eq', 'ne', 'gt', 'ge', 'lt', 'le', 'range'],
|
||||
'tags.sections' => ['range'] }.each do |field, operators|
|
||||
operators.each do |operator|
|
||||
condition = settings.merge('conditions.0.field' => field,
|
||||
'conditions.0.operator' => operator)
|
||||
empty = condition.merge('conditions.0.value' => '')
|
||||
expect(result(nil, condition)[:cells].sole[:count]).to eq(5)
|
||||
expect(result(nil, empty)[:cells].sole[:count]).to eq(5)
|
||||
expect(result(nil, empty.merge('conditions.0.blank' => '0'))[:cells]
|
||||
.sole[:count]).to eq(5)
|
||||
if operator == 'range'
|
||||
empty['conditions.0.end'] = ''
|
||||
expect(result(nil, empty)[:cells].sole[:count]).to eq(5)
|
||||
['conditions.0.value', 'conditions.0.end'].each do |key|
|
||||
active = empty.merge(key => '100')
|
||||
expect(result(nil, active)[:cells].sole[:count]).to eq(0)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
it 'keeps typed marker sets active after incompatible marker values are removed' do
|
||||
settings = { 'rows' => '', 'cols' => '', 'conditions' => '',
|
||||
'conditions.0.field' => 'video_ms', 'conditions.0.values' => '1',
|
||||
'condition_values' => '', 'condition_values.0.condition' => '0',
|
||||
'condition_values.0.value_type' => 'string',
|
||||
'condition_values.0.value' => '********' }
|
||||
['in', 'not_in'].each do |operator|
|
||||
expected = operator == 'in' ? 0 : 1
|
||||
condition = settings.merge('conditions.0.operator' => operator)
|
||||
expect(result(nil, condition)[:cells].sole[:count]).to eq(expected)
|
||||
end
|
||||
end
|
||||
|
||||
it 'restricts active numeric comparisons and preserves empty selection semantics' do
|
||||
settings = { 'rows' => '', 'cols' => '', 'conditions' => '',
|
||||
'conditions.0.field' => 'video_ms' }
|
||||
['eq', 'ne', 'gt', 'ge', 'lt', 'le', 'range'].each do |operator|
|
||||
condition = settings.merge('conditions.0.operator' => operator,
|
||||
'conditions.0.value' => '0')
|
||||
condition['conditions.0.end'] = '100' if operator == 'range'
|
||||
# The one public row is NULL; private NULLs and real values never match.
|
||||
expected = operator == 'ne' ? 1 : 0
|
||||
expect(result(nil, condition)[:cells].sole[:count]).to eq(expected)
|
||||
end
|
||||
['in', 'not_in'].each do |operator|
|
||||
condition = settings.merge('conditions.0.operator' => operator,
|
||||
'conditions.0.values' => '1', 'condition_values' => '')
|
||||
expected = operator == 'not_in' ? 5 : 0
|
||||
expect(result(nil, condition)[:cells].sole[:count]).to eq(expected)
|
||||
end
|
||||
blank = settings.merge('conditions.0.operator' => 'eq', 'conditions.0.blank' => '1')
|
||||
expect(result(nil, blank)[:cells].sole[:count]).to eq(1)
|
||||
end
|
||||
|
||||
it 'preserves empty tag sets and restricts active blank collection predicates' do
|
||||
settings = { 'rows' => '', 'cols' => '', 'conditions' => '',
|
||||
'conditions.0.field' => 'tags' }
|
||||
['any', 'all', 'none'].each do |operator|
|
||||
condition = settings.merge('conditions.0.operator' => operator,
|
||||
'conditions.0.values' => '1', 'condition_values' => '')
|
||||
expected = operator == 'any' ? 0 : 5
|
||||
expect(result(nil, condition)[:cells].sole[:count]).to eq(expected)
|
||||
end
|
||||
blank = settings.merge('conditions.0.operator' => 'eq', 'conditions.0.blank' => '1')
|
||||
expect(result(nil, blank)[:cells].sole[:count]).to eq(0)
|
||||
end
|
||||
|
||||
it 'rejects server-only coordinates in column order' do
|
||||
probes = [
|
||||
{ 'cols' => 'tags', 'column_order' => '', 'column_order.0.value_type' => 'string',
|
||||
'column_order.0.value' => CountsAxes::MASKED_VALUE }]
|
||||
HistoryAccess.set(user: nil) do
|
||||
probes.each do |probe|
|
||||
expect { CountsTableQuery.new(params.merge(probe)) }
|
||||
expect { CountsTableQuery.new(params.merge(probe)).call }
|
||||
.to raise_error(CountsAxes::PrivateCondition)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
it 'keeps private collection and change counts independent of stored elements' do
|
||||
settings = [
|
||||
{ 'rows' => 'tags', 'cols' => 'changed_field' },
|
||||
{ 'rows' => 'changed_field tags', 'cols' => '' }]
|
||||
original = settings.map { result(nil, _1) }
|
||||
snapshot = tags.map do |tag|
|
||||
{ tag_id: tag.id, version_no: 1, sections: [] }
|
||||
end
|
||||
hidden_posts = PostVersion.where(event_type: 'discard').pluck(:post_id)
|
||||
PostVersion.where(post_id: hidden_posts).update_all(
|
||||
title: 'same hidden value', tags_json: snapshot)
|
||||
expect(settings.map { result(nil, _1) }).to eq(original)
|
||||
end
|
||||
|
||||
it 'excludes private tag and change coordinates from drill conditions' do
|
||||
['tags', 'changed_field'].each do |field|
|
||||
settings = { 'rows' => field, 'cols' => '' }
|
||||
page = result(nil, settings)
|
||||
cell = page[:cells].find { _1[:values] == [CountsAxes::MASKED_VALUE] }
|
||||
expect(cell[:count]).to eq(4)
|
||||
[CountsAxes::MASKED_VALUE, CountsAxes::MASKED_TEXT].each do |value|
|
||||
drill = { 'drill' => '', 'drill.0.field' => field,
|
||||
'drill.0.value_type' => 'string', 'drill.0.value' => value }
|
||||
expect(result(nil, settings.merge(drill))[:cells]).to be_empty
|
||||
details = result(nil, settings.merge(drill).merge('mode' => 'details'))
|
||||
expect(details[:versions]).to be_empty
|
||||
condition = { 'conditions' => '', 'conditions.0.field' => field,
|
||||
'conditions.0.operator' => 'axis_eq',
|
||||
'conditions.0.value_type' => 'string', 'conditions.0.value' => value }
|
||||
expect(result(nil, settings.merge(condition))[:cells]).to be_empty
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
it 'rejects real private tag and change coordinates for non-admin users' do
|
||||
[nil, guest, member].each do |user|
|
||||
{ 'tags' => "internal:#{ tags.first.id }", 'changed_field' => 'title' }.each do
|
||||
|field, value|
|
||||
settings = { 'rows' => '', 'cols' => '', 'drill' => '',
|
||||
'drill.0.field' => field, 'drill.0.value_type' => 'string',
|
||||
'drill.0.value' => value }
|
||||
expect(result(user, settings)[:cells].sole[:count]).to eq(0)
|
||||
expect(result(admin, settings)[:cells].sole[:count]).to be > 0
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
it 'keeps public column sorting usable while guessed private coordinates match nothing' do
|
||||
first = result(nil, 'rows' => 'event_type', 'cols' => 'tags')
|
||||
probe = result(nil, 'rows' => 'event_type', 'cols' => 'tags',
|
||||
@@ -187,7 +357,7 @@ RSpec.describe 'Counts masked pivot contracts' do
|
||||
HistoryAccess.set(user: nil) do
|
||||
payload = params.merge('format' => 'json')
|
||||
json = JSON.parse(CountsTableExport.new(payload).body.to_a.join)
|
||||
expect(json['data'].sum { _1['kind'] == 'data' ? _1['count'] : 0 }).to eq(5)
|
||||
expect(json['data'].sum { _1['kind'] == 'data' ? _1['count'] : 0 }).to eq(6)
|
||||
expect(json.to_json).not_to include('hidden-title', 'private-discard')
|
||||
csv = CountsTableExport.new(payload.merge('format' => 'csv')).body.to_a.join
|
||||
expect(csv).to include('********')
|
||||
|
||||
@@ -8,21 +8,23 @@ RSpec.describe NicoTagVersionRecorder do
|
||||
described_class.record!(external_tag:, event_type:, created_by_user: member)
|
||||
end
|
||||
|
||||
it 'records the external association and platform-qualified name' do
|
||||
it 'records the external association and derives the platform-qualified name' do
|
||||
version = record(:create)
|
||||
|
||||
expect(version).to have_attributes(
|
||||
external_tag:, version_no: 1, event_type: 'create',
|
||||
name: 'nico:raw tag[]', linked_tags: '', created_by_user: member)
|
||||
name: 'nico:raw tag[]', linked_tag_ids: '', created_by_user: member)
|
||||
expect(external_tag.reload.nico_tag_versions).to contain_exactly(version)
|
||||
end
|
||||
|
||||
it 'uses the latest history number when the record has no version_no column' do
|
||||
first = record(:create)
|
||||
external_tag.update!(name: 'changed')
|
||||
tag = create(:tag)
|
||||
NicoTagRelation.create!(nico_tag: external_tag, tag:)
|
||||
second = record(:update)
|
||||
|
||||
expect(second).to have_attributes(version_no: 2, name: 'nico:changed')
|
||||
expect(second).to have_attributes(version_no: 2, name: 'nico:raw tag[]',
|
||||
linked_tag_ids: tag.id.to_s)
|
||||
expect(first.reload.name).to eq('nico:raw tag[]')
|
||||
expect(external_tag.reload.has_attribute?(:version_no)).to be(false)
|
||||
end
|
||||
@@ -40,7 +42,7 @@ RSpec.describe NicoTagVersionRecorder do
|
||||
|
||||
expect(version).to have_attributes(
|
||||
external_tag: external, name: 'registered_external:foo', version_no: 1,
|
||||
event_type: 'create', linked_tags: '', created_by_user: member)
|
||||
event_type: 'create', linked_tag_ids: '', created_by_user: member)
|
||||
end
|
||||
|
||||
it 'returns the latest version without appending an unchanged snapshot' do
|
||||
@@ -64,13 +66,21 @@ RSpec.describe NicoTagVersionRecorder do
|
||||
expect(external_tag.nico_tag_versions).to contain_exactly(first)
|
||||
end
|
||||
|
||||
it 'records sorted linked internal names and later link removal' do
|
||||
|
||||
it 'rejects discard and restore events for external tags' do
|
||||
[:discard, :restore].each do |event_type|
|
||||
expect { record(event_type) }
|
||||
.to raise_error(ArgumentError, "Invalid event_type: #{ event_type }")
|
||||
end
|
||||
end
|
||||
|
||||
it 'records sorted linked internal IDs and later link removal' do
|
||||
tags = ['z_link', 'a_link'].map { |name| create(:tag, primary_name: name, category: :general) }
|
||||
tags.each { |tag| NicoTagRelation.create!(nico_tag: external_tag, tag:) }
|
||||
|
||||
expect(record(:create).linked_tags).to eq('a_link z_link')
|
||||
expect(record(:create).linked_tag_ids).to eq(tags.map(&:id).sort.join(' '))
|
||||
external_tag.linked_tags = [tags.first]
|
||||
|
||||
expect(record(:update)).to have_attributes(version_no: 2, linked_tags: 'z_link')
|
||||
expect(record(:update)).to have_attributes(version_no: 2, linked_tag_ids: tags.first.id.to_s)
|
||||
end
|
||||
end
|
||||
|
||||
+3366
ファイル差分が大きすぎるため省略します
差分を読み込み
+12
-4
@@ -158,7 +158,15 @@ private ID の正誤に応じて許可可否を変えないため、条件の一
|
||||
非公開列を含む列順変更は API の column_values_editable でも不可と通知する。
|
||||
公開列の値を使うソートは引き続き利用できる。
|
||||
明細には元の認可を適用し、抽出条件自体や取得済みの全言語値を表示設定で改変しない。
|
||||
外部タグ履歴の linked_tags は名前しか記録していないため、非公開タグの記録名を含む
|
||||
スナップショットを保守的に非公開とする。名前全体を空白区切りの境界で照合する。
|
||||
区切りが曖昧な旧データでは過剰な秘匿の可能性はあるが、参照 ID がない旧形式から
|
||||
名前の公開可否を楽観的に推定しない。
|
||||
外部タグ履歴の linked_tag_ids は、連携する内部タグの ID を半角スペース区切りで
|
||||
記録する。除票履歴のある内部タグの ID を含むスナップショットを非管理者へ公開しない。
|
||||
名前や UI ロカールを参照 ID の判定に使わない。
|
||||
外部タグ名は履歴に保存せず、external_tags の platform と name を参照する。
|
||||
|
||||
V1 Counts の公開フィールド名は linked_tags を維持し、DB の linked_tag_ids へ対応付ける。
|
||||
明細・フィルタ・集計軸・変更項目・ソート・CSV / JSON 出力で同じ対応付けを使う。
|
||||
既定列と既存の表示ラベルも linked_tags のままとし、値は ID のスペース区切りとする。
|
||||
linked_tag_ids は物理カラム名であり、Counts の公開フィールドとしては受け付けない。
|
||||
旧 URL や保存済み設定のフィールド指定は維持できるが、値にタグ名を指定した検索条件や
|
||||
ドリル条件は ID 値への見直しが必要となる。旧履歴の名称は移行後の ID 列から復元できず、
|
||||
現在の名称からの推定変換も行わない。
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { fireEvent, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import TagSearch from '@/components/TagSearch'
|
||||
import * as api from '@/lib/api'
|
||||
import { buildTag } from '@/test/factories'
|
||||
import { renderWithProviders } from '@/test/render'
|
||||
|
||||
|
||||
const LocationOutput = () => {
|
||||
const location = useLocation ()
|
||||
return <output data-testid="location">{location.pathname}{location.search}</output>
|
||||
}
|
||||
|
||||
|
||||
const renderSearch = () => renderWithProviders (
|
||||
<>
|
||||
<TagSearch/>
|
||||
<LocationOutput/>
|
||||
</>, { route: '/ja/posts?page=3' })
|
||||
|
||||
|
||||
describe ('TagSearch', () => {
|
||||
beforeEach (() => {
|
||||
vi.spyOn (api, 'apiGet').mockResolvedValue ([])
|
||||
})
|
||||
|
||||
it ('searches through native submission without an Enter key event', () => {
|
||||
renderSearch ()
|
||||
const input = screen.getByRole<HTMLInputElement> ('textbox')
|
||||
fireEvent.change (input, { target: { value: '虹夏 not:TEST type:or' } })
|
||||
|
||||
expect (input.form).toBeInstanceOf (HTMLFormElement)
|
||||
fireEvent.submit (input.form!)
|
||||
|
||||
const query = new URLSearchParams ({ tags: '虹夏 not:test', match: 'any' })
|
||||
expect (screen.getByTestId ('location')).toHaveTextContent (
|
||||
`/ja/posts?${ query }`)
|
||||
expect (input).toHaveValue ('虹夏 not:test type:or')
|
||||
})
|
||||
|
||||
it ('requests a search action from the software keyboard', () => {
|
||||
renderSearch ()
|
||||
|
||||
expect (screen.getByRole ('textbox')).toHaveAttribute ('enterkeyhint', 'search')
|
||||
})
|
||||
|
||||
it ('searches through the form when Enter is pressed', async () => {
|
||||
const user = userEvent.setup ()
|
||||
renderSearch ()
|
||||
|
||||
await user.type (screen.getByRole ('textbox'), '虹夏{Enter}')
|
||||
|
||||
const query = new URLSearchParams ({ tags: '虹夏', match: 'all' })
|
||||
expect (screen.getByTestId ('location')).toHaveTextContent (
|
||||
`/ja/posts?${ query }`)
|
||||
})
|
||||
|
||||
it.each ([
|
||||
{ label: 'composition flag', composing: true, keyCode: 13 },
|
||||
{ label: 'legacy IME code', composing: false, keyCode: 229 }]) (
|
||||
'cancels confirmation Enter with $label', ({ composing, keyCode }) => {
|
||||
renderSearch ()
|
||||
const input = screen.getByRole ('textbox')
|
||||
fireEvent.change (input, { target: { value: '虹夏' } })
|
||||
const uncancelled = fireEvent.keyDown (input, {
|
||||
key: 'Enter', isComposing: composing, keyCode })
|
||||
|
||||
expect (uncancelled).toBe (false)
|
||||
expect (screen.getByTestId ('location')).toHaveTextContent ('/ja/posts?page=3')
|
||||
expect (input).toHaveValue ('虹夏')
|
||||
})
|
||||
|
||||
it ('cancels Enter throughout composition even without keyboard IME flags', () => {
|
||||
renderSearch ()
|
||||
const input = screen.getByRole ('textbox')
|
||||
fireEvent.compositionStart (input)
|
||||
fireEvent.change (input, { target: { value: '虹夏' } })
|
||||
const uncancelled = fireEvent.keyDown (input, {
|
||||
key: 'Enter', isComposing: false, keyCode: 13 })
|
||||
|
||||
expect (uncancelled).toBe (false)
|
||||
expect (screen.getByTestId ('location')).toHaveTextContent ('/ja/posts?page=3')
|
||||
expect (input).toHaveValue ('虹夏')
|
||||
})
|
||||
|
||||
it ('blocks direct submission during composition and allows it after confirmation', () => {
|
||||
renderSearch ()
|
||||
const input = screen.getByRole<HTMLInputElement> ('textbox')
|
||||
fireEvent.compositionStart (input)
|
||||
fireEvent.change (input, { target: { value: '虹夏' } })
|
||||
fireEvent.submit (input.form!)
|
||||
|
||||
expect (screen.getByTestId ('location')).toHaveTextContent ('/ja/posts?page=3')
|
||||
expect (input).toHaveValue ('虹夏')
|
||||
|
||||
fireEvent.compositionEnd (input)
|
||||
fireEvent.submit (input.form!)
|
||||
|
||||
const query = new URLSearchParams ({ tags: '虹夏', match: 'all' })
|
||||
expect (screen.getByTestId ('location')).toHaveTextContent (
|
||||
`/ja/posts?${ query }`)
|
||||
})
|
||||
|
||||
it ('selects the active suggestion without submitting the form', async () => {
|
||||
vi.mocked (api.apiGet).mockResolvedValue ([buildTag ({ name: '虹夏' })])
|
||||
const user = userEvent.setup ()
|
||||
renderSearch ()
|
||||
|
||||
await user.type (screen.getByRole ('textbox'), '虹')
|
||||
await screen.findByText ('虹夏')
|
||||
await user.keyboard ('{ArrowDown}{Enter}')
|
||||
|
||||
expect (screen.getByRole ('textbox')).toHaveValue ('虹夏 ')
|
||||
expect (screen.getByTestId ('location')).toHaveTextContent ('/ja/posts?page=3')
|
||||
expect (screen.queryByText ('虹夏')).not.toBeInTheDocument ()
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
// TODO: タグ入力系すべてに同様の処理あるため共通化する.
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
|
||||
import { useLocale, useLocaleNavigate } from '@/lib/useLocale'
|
||||
@@ -10,7 +10,7 @@ import { inputClass } from '@/lib/utils'
|
||||
|
||||
import TagSearchBox from './TagSearchBox'
|
||||
|
||||
import type { ChangeEvent, FC, KeyboardEvent } from 'react'
|
||||
import type { ChangeEvent, FC, FormEvent, KeyboardEvent } from 'react'
|
||||
|
||||
import type { Tag } from '@/types'
|
||||
|
||||
@@ -20,6 +20,7 @@ const TagSearch: FC = () => {
|
||||
const locale = useLocale ()
|
||||
const location = useLocation ()
|
||||
const navigate = useLocaleNavigate ()
|
||||
const compositionFlgRef = useRef (false)
|
||||
|
||||
const [activeIndex, setActiveIndex] = useState (-1)
|
||||
const [search, setSearch] = useState ('')
|
||||
@@ -74,7 +75,34 @@ const TagSearch: FC = () => {
|
||||
setSuggestionsVsbl (false)
|
||||
}, [locale])
|
||||
|
||||
const handleSubmit = (ev: FormEvent<HTMLFormElement>) => {
|
||||
ev.preventDefault ()
|
||||
if (compositionFlgRef.current)
|
||||
return
|
||||
|
||||
const parts = search.split (' ')
|
||||
const match =
|
||||
parts.map (t => t.toLowerCase ()).includes ('type:or') ? 'any' : 'all'
|
||||
navigate (`/posts?${
|
||||
(new URLSearchParams ({
|
||||
tags: parts.map (t => t.toLowerCase ())
|
||||
.filter (t => t !== 'type:or')
|
||||
.join (' '),
|
||||
match })).toString () }`)
|
||||
setSuggestionsVsbl (false)
|
||||
setRequest ({ query: '' })
|
||||
}
|
||||
|
||||
const handleKeyDown = (ev: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (compositionFlgRef.current
|
||||
|| ev.nativeEvent.isComposing
|
||||
|| ev.nativeEvent.keyCode === 229)
|
||||
{
|
||||
if (ev.key === 'Enter')
|
||||
ev.preventDefault ()
|
||||
return
|
||||
}
|
||||
|
||||
switch (ev.key)
|
||||
{
|
||||
case 'ArrowDown':
|
||||
@@ -90,13 +118,12 @@ const TagSearch: FC = () => {
|
||||
break
|
||||
|
||||
case 'Enter':
|
||||
if (activeIndex < 0)
|
||||
break
|
||||
ev.preventDefault ()
|
||||
{
|
||||
const selected = suggestions[activeIndex]
|
||||
if (selected)
|
||||
handleTagSelect (selected)
|
||||
const selected = suggestionsVsbl ? suggestions[activeIndex] : null
|
||||
if (selected == null)
|
||||
break
|
||||
ev.preventDefault ()
|
||||
handleTagSelect (selected)
|
||||
}
|
||||
break
|
||||
|
||||
@@ -105,20 +132,6 @@ const TagSearch: FC = () => {
|
||||
setSuggestionsVsbl (false)
|
||||
break
|
||||
}
|
||||
if (ev.key === 'Enter' && (!(suggestionsVsbl) || activeIndex < 0))
|
||||
{
|
||||
const parts = search.split (' ')
|
||||
const match = parts.map (t => t.toLowerCase ()).includes ('type:or') ? 'any' : 'all'
|
||||
navigate (`/posts?${
|
||||
(new URLSearchParams ({
|
||||
tags: parts.map (t => t.toLowerCase ())
|
||||
.filter (t => t !== 'type:or')
|
||||
.join (' '),
|
||||
match }))
|
||||
.toString () }`)
|
||||
setSuggestionsVsbl (false)
|
||||
setRequest ({ query: '' })
|
||||
}
|
||||
}
|
||||
|
||||
const handleTagSelect = (tag: Tag) => {
|
||||
@@ -144,8 +157,9 @@ const TagSearch: FC = () => {
|
||||
}, [location.search])
|
||||
|
||||
return (
|
||||
<div className="relative w-full">
|
||||
<form onSubmit={handleSubmit} className="relative w-full">
|
||||
<input type="text"
|
||||
enterKeyHint="search"
|
||||
data-shortcut-focus-search="true"
|
||||
placeholder={message ('labels.tagSearch').text}
|
||||
value={search}
|
||||
@@ -153,12 +167,18 @@ const TagSearch: FC = () => {
|
||||
onFocus={() => setSuggestionsVsbl (true)}
|
||||
onBlur={() => setSuggestionsVsbl (false)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onCompositionStart={() => {
|
||||
compositionFlgRef.current = true
|
||||
}}
|
||||
onCompositionEnd={() => {
|
||||
compositionFlgRef.current = false
|
||||
}}
|
||||
className={inputClass (false,
|
||||
'px-3 py-2 dark:border-gray-600 dark:bg-gray-800 dark:text-white')}/>
|
||||
<TagSearchBox suggestions={suggestionsVsbl && suggestions.length ? suggestions : [] as Tag[]}
|
||||
activeIndex={activeIndex}
|
||||
onSelect={handleTagSelect}/>
|
||||
</div>)
|
||||
</form>)
|
||||
}
|
||||
|
||||
export default TagSearch
|
||||
|
||||
@@ -70,6 +70,16 @@ describe ('SidebarComponent', () => {
|
||||
vi.spyOn (HTMLElement.prototype, 'hasPointerCapture').mockReturnValue (true)
|
||||
})
|
||||
|
||||
it ('limits sidebar scrolling to desktop to avoid clipping mobile suggestions', () => {
|
||||
renderSidebar ()
|
||||
|
||||
// jsdom has no layout; guard the responsive classes responsible for clipping.
|
||||
const sidebar = screen.getByText ('Sidebar body')
|
||||
|
||||
expect (sidebar).not.toHaveClass ('overflow-y-auto')
|
||||
expect (sidebar).toHaveClass ('md:overflow-y-auto')
|
||||
})
|
||||
|
||||
it ('uses maxWidth without applying the container main-width reserve twice', async () => {
|
||||
localStorage.setItem ('sidebar:test-sidebar:left:width', '700')
|
||||
const onWidthChange = vi.fn ()
|
||||
|
||||
@@ -339,7 +339,7 @@ const SidebarComponent: FC<Props> = ({
|
||||
</style>
|
||||
</Helmet>
|
||||
|
||||
<div className="h-full overflow-y-auto p-4 sidebar">
|
||||
<div className="h-full md:overflow-y-auto p-4 sidebar">
|
||||
{children}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -35,6 +35,10 @@ const renderPage = () =>
|
||||
describe ('TagDetailPage', () => {
|
||||
beforeEach (() => {
|
||||
vi.clearAllMocks ()
|
||||
tagsApi.fetchTag.mockReset ()
|
||||
api.apiPut.mockReset ()
|
||||
api.isApiError.mockReset ()
|
||||
toastApi.toast.mockReset ()
|
||||
api.isApiError.mockReturnValue (false)
|
||||
})
|
||||
|
||||
@@ -51,8 +55,11 @@ describe ('TagDetailPage', () => {
|
||||
})
|
||||
|
||||
it ('submits edited tag fields', async () => {
|
||||
tagsApi.fetchTag.mockResolvedValueOnce (buildTag ({ id: 7, name: 'old' }))
|
||||
api.apiPut.mockResolvedValueOnce (buildTag ({ id: 7, name: 'new', aliases: ['alias'] }))
|
||||
const updatedTag = buildTag ({ id: 7, name: 'new', aliases: ['alias'] })
|
||||
tagsApi.fetchTag
|
||||
.mockResolvedValue (updatedTag)
|
||||
.mockResolvedValueOnce (buildTag ({ id: 7, name: 'old' }))
|
||||
api.apiPut.mockResolvedValueOnce (updatedTag)
|
||||
|
||||
renderPage ()
|
||||
|
||||
|
||||
新しいイシューから参照
ユーザーをブロックする