コミットを比較
3
コミット
| 作成者 | SHA1 | 日付 | |
|---|---|---|---|
|
|
971b2632b0 | ||
|
|
ad04c03e14 | ||
|
|
2197a9192b |
@@ -71,5 +71,3 @@ gem "rspec-rails", "~> 8.0", :groups => [:development, :test]
|
|||||||
gem 'aws-sdk-s3', require: false
|
gem 'aws-sdk-s3', require: false
|
||||||
|
|
||||||
gem 'rails-i18n', '~> 8.0.0'
|
gem 'rails-i18n', '~> 8.0.0'
|
||||||
|
|
||||||
gem "json", "~> 2.19.9"
|
|
||||||
|
|||||||
+134
-142
@@ -1,29 +1,29 @@
|
|||||||
GEM
|
GEM
|
||||||
remote: https://rubygems.org/
|
remote: https://rubygems.org/
|
||||||
specs:
|
specs:
|
||||||
actioncable (8.0.5.1)
|
actioncable (8.0.2)
|
||||||
actionpack (= 8.0.5.1)
|
actionpack (= 8.0.2)
|
||||||
activesupport (= 8.0.5.1)
|
activesupport (= 8.0.2)
|
||||||
nio4r (~> 2.0)
|
nio4r (~> 2.0)
|
||||||
websocket-driver (>= 0.6.1)
|
websocket-driver (>= 0.6.1)
|
||||||
zeitwerk (~> 2.6)
|
zeitwerk (~> 2.6)
|
||||||
actionmailbox (8.0.5.1)
|
actionmailbox (8.0.2)
|
||||||
actionpack (= 8.0.5.1)
|
actionpack (= 8.0.2)
|
||||||
activejob (= 8.0.5.1)
|
activejob (= 8.0.2)
|
||||||
activerecord (= 8.0.5.1)
|
activerecord (= 8.0.2)
|
||||||
activestorage (= 8.0.5.1)
|
activestorage (= 8.0.2)
|
||||||
activesupport (= 8.0.5.1)
|
activesupport (= 8.0.2)
|
||||||
mail (>= 2.8.0)
|
mail (>= 2.8.0)
|
||||||
actionmailer (8.0.5.1)
|
actionmailer (8.0.2)
|
||||||
actionpack (= 8.0.5.1)
|
actionpack (= 8.0.2)
|
||||||
actionview (= 8.0.5.1)
|
actionview (= 8.0.2)
|
||||||
activejob (= 8.0.5.1)
|
activejob (= 8.0.2)
|
||||||
activesupport (= 8.0.5.1)
|
activesupport (= 8.0.2)
|
||||||
mail (>= 2.8.0)
|
mail (>= 2.8.0)
|
||||||
rails-dom-testing (~> 2.2)
|
rails-dom-testing (~> 2.2)
|
||||||
actionpack (8.0.5.1)
|
actionpack (8.0.2)
|
||||||
actionview (= 8.0.5.1)
|
actionview (= 8.0.2)
|
||||||
activesupport (= 8.0.5.1)
|
activesupport (= 8.0.2)
|
||||||
nokogiri (>= 1.8.5)
|
nokogiri (>= 1.8.5)
|
||||||
rack (>= 2.2.4)
|
rack (>= 2.2.4)
|
||||||
rack-session (>= 1.0.1)
|
rack-session (>= 1.0.1)
|
||||||
@@ -31,35 +31,35 @@ GEM
|
|||||||
rails-dom-testing (~> 2.2)
|
rails-dom-testing (~> 2.2)
|
||||||
rails-html-sanitizer (~> 1.6)
|
rails-html-sanitizer (~> 1.6)
|
||||||
useragent (~> 0.16)
|
useragent (~> 0.16)
|
||||||
actiontext (8.0.5.1)
|
actiontext (8.0.2)
|
||||||
actionpack (= 8.0.5.1)
|
actionpack (= 8.0.2)
|
||||||
activerecord (= 8.0.5.1)
|
activerecord (= 8.0.2)
|
||||||
activestorage (= 8.0.5.1)
|
activestorage (= 8.0.2)
|
||||||
activesupport (= 8.0.5.1)
|
activesupport (= 8.0.2)
|
||||||
globalid (>= 0.6.0)
|
globalid (>= 0.6.0)
|
||||||
nokogiri (>= 1.8.5)
|
nokogiri (>= 1.8.5)
|
||||||
actionview (8.0.5.1)
|
actionview (8.0.2)
|
||||||
activesupport (= 8.0.5.1)
|
activesupport (= 8.0.2)
|
||||||
builder (~> 3.1)
|
builder (~> 3.1)
|
||||||
erubi (~> 1.11)
|
erubi (~> 1.11)
|
||||||
rails-dom-testing (~> 2.2)
|
rails-dom-testing (~> 2.2)
|
||||||
rails-html-sanitizer (~> 1.6)
|
rails-html-sanitizer (~> 1.6)
|
||||||
activejob (8.0.5.1)
|
activejob (8.0.2)
|
||||||
activesupport (= 8.0.5.1)
|
activesupport (= 8.0.2)
|
||||||
globalid (>= 0.3.6)
|
globalid (>= 0.3.6)
|
||||||
activemodel (8.0.5.1)
|
activemodel (8.0.2)
|
||||||
activesupport (= 8.0.5.1)
|
activesupport (= 8.0.2)
|
||||||
activerecord (8.0.5.1)
|
activerecord (8.0.2)
|
||||||
activemodel (= 8.0.5.1)
|
activemodel (= 8.0.2)
|
||||||
activesupport (= 8.0.5.1)
|
activesupport (= 8.0.2)
|
||||||
timeout (>= 0.4.0)
|
timeout (>= 0.4.0)
|
||||||
activestorage (8.0.5.1)
|
activestorage (8.0.2)
|
||||||
actionpack (= 8.0.5.1)
|
actionpack (= 8.0.2)
|
||||||
activejob (= 8.0.5.1)
|
activejob (= 8.0.2)
|
||||||
activerecord (= 8.0.5.1)
|
activerecord (= 8.0.2)
|
||||||
activesupport (= 8.0.5.1)
|
activesupport (= 8.0.2)
|
||||||
marcel (~> 1.0)
|
marcel (~> 1.0)
|
||||||
activesupport (8.0.5.1)
|
activesupport (8.0.2)
|
||||||
base64
|
base64
|
||||||
benchmark (>= 0.3)
|
benchmark (>= 0.3)
|
||||||
bigdecimal
|
bigdecimal
|
||||||
@@ -92,22 +92,22 @@ GEM
|
|||||||
aws-sigv4 (~> 1.5)
|
aws-sigv4 (~> 1.5)
|
||||||
aws-sigv4 (1.12.1)
|
aws-sigv4 (1.12.1)
|
||||||
aws-eventstream (~> 1, >= 1.0.2)
|
aws-eventstream (~> 1, >= 1.0.2)
|
||||||
base64 (0.3.0)
|
base64 (0.2.0)
|
||||||
bcrypt_pbkdf (1.1.1)
|
bcrypt_pbkdf (1.1.1)
|
||||||
bcrypt_pbkdf (1.1.1-arm64-darwin)
|
bcrypt_pbkdf (1.1.1-arm64-darwin)
|
||||||
bcrypt_pbkdf (1.1.1-x86_64-darwin)
|
bcrypt_pbkdf (1.1.1-x86_64-darwin)
|
||||||
benchmark (0.5.0)
|
benchmark (0.4.0)
|
||||||
bigdecimal (4.1.3)
|
bigdecimal (3.1.9)
|
||||||
bootsnap (1.18.6)
|
bootsnap (1.18.6)
|
||||||
msgpack (~> 1.2)
|
msgpack (~> 1.2)
|
||||||
brakeman (7.0.2)
|
brakeman (7.0.2)
|
||||||
racc
|
racc
|
||||||
builder (3.3.0)
|
builder (3.3.0)
|
||||||
chronic (0.10.2)
|
chronic (0.10.2)
|
||||||
concurrent-ruby (1.3.8)
|
concurrent-ruby (1.3.5)
|
||||||
connection_pool (3.0.2)
|
connection_pool (2.5.3)
|
||||||
crass (1.0.7)
|
crass (1.0.6)
|
||||||
date (3.6.0)
|
date (3.4.1)
|
||||||
diff-lcs (1.6.2)
|
diff-lcs (1.6.2)
|
||||||
discard (1.4.0)
|
discard (1.4.0)
|
||||||
activerecord (>= 4.2, < 9.0)
|
activerecord (>= 4.2, < 9.0)
|
||||||
@@ -115,9 +115,8 @@ GEM
|
|||||||
dotenv-rails (3.1.8)
|
dotenv-rails (3.1.8)
|
||||||
dotenv (= 3.1.8)
|
dotenv (= 3.1.8)
|
||||||
railties (>= 6.1)
|
railties (>= 6.1)
|
||||||
drb (2.2.3)
|
drb (2.2.1)
|
||||||
ed25519 (1.4.0)
|
ed25519 (1.4.0)
|
||||||
erb (6.0.7)
|
|
||||||
erubi (1.13.1)
|
erubi (1.13.1)
|
||||||
factory_bot (6.5.6)
|
factory_bot (6.5.6)
|
||||||
activesupport (>= 6.1.0)
|
activesupport (>= 6.1.0)
|
||||||
@@ -135,7 +134,7 @@ GEM
|
|||||||
gemojione (4.3.3)
|
gemojione (4.3.3)
|
||||||
json
|
json
|
||||||
github-markup (4.0.2)
|
github-markup (4.0.2)
|
||||||
globalid (1.4.0)
|
globalid (1.2.1)
|
||||||
activesupport (>= 6.1)
|
activesupport (>= 6.1)
|
||||||
gollum (6.1.0)
|
gollum (6.1.0)
|
||||||
gemojione (~> 4.1)
|
gemojione (~> 4.1)
|
||||||
@@ -167,20 +166,19 @@ GEM
|
|||||||
gollum-rugged_adapter (3.0)
|
gollum-rugged_adapter (3.0)
|
||||||
mime-types (~> 3.4)
|
mime-types (~> 3.4)
|
||||||
rugged (~> 1.5)
|
rugged (~> 1.5)
|
||||||
i18n (1.15.2)
|
i18n (1.14.7)
|
||||||
concurrent-ruby (~> 1.0)
|
concurrent-ruby (~> 1.0)
|
||||||
image_processing (1.14.0)
|
image_processing (1.14.0)
|
||||||
mini_magick (>= 4.9.5, < 6)
|
mini_magick (>= 4.9.5, < 6)
|
||||||
ruby-vips (>= 2.0.17, < 3)
|
ruby-vips (>= 2.0.17, < 3)
|
||||||
io-console (0.9.4)
|
io-console (0.8.0)
|
||||||
irb (1.18.0)
|
irb (1.15.2)
|
||||||
pp (>= 0.6.0)
|
pp (>= 0.6.0)
|
||||||
prism (>= 1.3.0)
|
|
||||||
rdoc (>= 4.0.0)
|
rdoc (>= 4.0.0)
|
||||||
reline (>= 0.4.2)
|
reline (>= 0.4.2)
|
||||||
jmespath (1.6.2)
|
jmespath (1.6.2)
|
||||||
json (2.19.9)
|
json (2.12.0)
|
||||||
jwt (3.3.0)
|
jwt (2.10.1)
|
||||||
base64
|
base64
|
||||||
kamal (2.6.1)
|
kamal (2.6.1)
|
||||||
activesupport (>= 7.0)
|
activesupport (>= 7.0)
|
||||||
@@ -200,16 +198,15 @@ GEM
|
|||||||
language_server-protocol (3.17.0.5)
|
language_server-protocol (3.17.0.5)
|
||||||
lint_roller (1.1.0)
|
lint_roller (1.1.0)
|
||||||
logger (1.7.0)
|
logger (1.7.0)
|
||||||
loofah (2.25.2)
|
loofah (2.24.1)
|
||||||
crass (~> 1.0.2)
|
crass (~> 1.0.2)
|
||||||
nokogiri (>= 1.12.0)
|
nokogiri (>= 1.12.0)
|
||||||
mail (2.9.1)
|
mail (2.8.1)
|
||||||
logger
|
|
||||||
mini_mime (>= 0.1.1)
|
mini_mime (>= 0.1.1)
|
||||||
net-imap
|
net-imap
|
||||||
net-pop
|
net-pop
|
||||||
net-smtp
|
net-smtp
|
||||||
marcel (1.2.1)
|
marcel (1.0.4)
|
||||||
mime-types (3.7.0)
|
mime-types (3.7.0)
|
||||||
logger
|
logger
|
||||||
mime-types-data (~> 3.2025, >= 3.2025.0507)
|
mime-types-data (~> 3.2025, >= 3.2025.0507)
|
||||||
@@ -218,46 +215,45 @@ GEM
|
|||||||
benchmark
|
benchmark
|
||||||
logger
|
logger
|
||||||
mini_mime (1.1.5)
|
mini_mime (1.1.5)
|
||||||
minitest (6.0.6)
|
minitest (5.25.5)
|
||||||
drb (~> 2.0)
|
msgpack (1.8.0)
|
||||||
prism (~> 1.5)
|
multi_json (1.15.0)
|
||||||
msgpack (1.8.5)
|
|
||||||
multi_json (1.21.2)
|
|
||||||
mustache (1.1.1)
|
mustache (1.1.1)
|
||||||
mustache-sinatra (2.0.0)
|
mustache-sinatra (2.0.0)
|
||||||
mustache (~> 1.0)
|
mustache (~> 1.0)
|
||||||
mustermann (3.1.1)
|
mustermann (3.0.3)
|
||||||
|
ruby2_keywords (~> 0.0.1)
|
||||||
mysql2 (0.5.6)
|
mysql2 (0.5.6)
|
||||||
net-imap (0.6.8)
|
net-imap (0.5.8)
|
||||||
date
|
date
|
||||||
net-protocol
|
net-protocol
|
||||||
net-pop (0.1.2)
|
net-pop (0.1.2)
|
||||||
net-protocol
|
net-protocol
|
||||||
net-protocol (0.4.0)
|
net-protocol (0.2.2)
|
||||||
timeout
|
timeout
|
||||||
net-scp (4.1.0)
|
net-scp (4.1.0)
|
||||||
net-ssh (>= 2.6.5, < 8.0.0)
|
net-ssh (>= 2.6.5, < 8.0.0)
|
||||||
net-sftp (4.0.0)
|
net-sftp (4.0.0)
|
||||||
net-ssh (>= 5.0.0, < 8.0.0)
|
net-ssh (>= 5.0.0, < 8.0.0)
|
||||||
net-smtp (0.5.2)
|
net-smtp (0.5.1)
|
||||||
net-protocol
|
net-protocol
|
||||||
net-ssh (7.3.0)
|
net-ssh (7.3.0)
|
||||||
nio4r (2.7.5)
|
nio4r (2.7.4)
|
||||||
nokogiri (1.19.4-aarch64-linux-gnu)
|
nokogiri (1.18.8-aarch64-linux-gnu)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
nokogiri (1.19.4-aarch64-linux-musl)
|
nokogiri (1.18.8-aarch64-linux-musl)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
nokogiri (1.19.4-arm-linux-gnu)
|
nokogiri (1.18.8-arm-linux-gnu)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
nokogiri (1.19.4-arm-linux-musl)
|
nokogiri (1.18.8-arm-linux-musl)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
nokogiri (1.19.4-arm64-darwin)
|
nokogiri (1.18.8-arm64-darwin)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
nokogiri (1.19.4-x86_64-darwin)
|
nokogiri (1.18.8-x86_64-darwin)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
nokogiri (1.19.4-x86_64-linux-gnu)
|
nokogiri (1.18.8-x86_64-linux-gnu)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
nokogiri (1.19.4-x86_64-linux-musl)
|
nokogiri (1.18.8-x86_64-linux-musl)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
octicons (19.15.2)
|
octicons (19.15.2)
|
||||||
ostruct (0.6.1)
|
ostruct (0.6.1)
|
||||||
@@ -265,73 +261,70 @@ GEM
|
|||||||
parser (3.3.8.0)
|
parser (3.3.8.0)
|
||||||
ast (~> 2.4.1)
|
ast (~> 2.4.1)
|
||||||
racc
|
racc
|
||||||
pp (0.6.4)
|
pp (0.6.2)
|
||||||
prettyprint
|
prettyprint
|
||||||
prettyprint (0.2.1)
|
prettyprint (0.2.0)
|
||||||
prism (1.9.0)
|
prism (1.4.0)
|
||||||
psych (5.5.0)
|
psych (5.2.6)
|
||||||
date
|
date
|
||||||
stringio
|
stringio
|
||||||
puma (6.6.0)
|
puma (6.6.0)
|
||||||
nio4r (~> 2.0)
|
nio4r (~> 2.0)
|
||||||
racc (1.8.1)
|
racc (1.8.1)
|
||||||
rack (3.2.7)
|
rack (3.1.14)
|
||||||
rack-cors (2.0.2)
|
rack-cors (2.0.2)
|
||||||
rack (>= 2.0.0)
|
rack (>= 2.0.0)
|
||||||
rack-protection (4.2.1)
|
rack-protection (4.1.1)
|
||||||
base64 (>= 0.1.0)
|
base64 (>= 0.1.0)
|
||||||
logger (>= 1.6.0)
|
logger (>= 1.6.0)
|
||||||
rack (>= 3.0.0, < 4)
|
rack (>= 3.0.0, < 4)
|
||||||
rack-session (2.1.2)
|
rack-session (2.1.1)
|
||||||
base64 (>= 0.1.0)
|
base64 (>= 0.1.0)
|
||||||
rack (>= 3.0.0)
|
rack (>= 3.0.0)
|
||||||
rack-test (2.3.0)
|
rack-test (2.2.0)
|
||||||
rack (>= 1.3)
|
rack (>= 1.3)
|
||||||
rackup (2.3.1)
|
rackup (2.2.1)
|
||||||
rack (>= 3)
|
rack (>= 3)
|
||||||
rails (8.0.5.1)
|
rails (8.0.2)
|
||||||
actioncable (= 8.0.5.1)
|
actioncable (= 8.0.2)
|
||||||
actionmailbox (= 8.0.5.1)
|
actionmailbox (= 8.0.2)
|
||||||
actionmailer (= 8.0.5.1)
|
actionmailer (= 8.0.2)
|
||||||
actionpack (= 8.0.5.1)
|
actionpack (= 8.0.2)
|
||||||
actiontext (= 8.0.5.1)
|
actiontext (= 8.0.2)
|
||||||
actionview (= 8.0.5.1)
|
actionview (= 8.0.2)
|
||||||
activejob (= 8.0.5.1)
|
activejob (= 8.0.2)
|
||||||
activemodel (= 8.0.5.1)
|
activemodel (= 8.0.2)
|
||||||
activerecord (= 8.0.5.1)
|
activerecord (= 8.0.2)
|
||||||
activestorage (= 8.0.5.1)
|
activestorage (= 8.0.2)
|
||||||
activesupport (= 8.0.5.1)
|
activesupport (= 8.0.2)
|
||||||
bundler (>= 1.15.0)
|
bundler (>= 1.15.0)
|
||||||
railties (= 8.0.5.1)
|
railties (= 8.0.2)
|
||||||
rails-dom-testing (2.3.0)
|
rails-dom-testing (2.2.0)
|
||||||
activesupport (>= 5.0.0)
|
activesupport (>= 5.0.0)
|
||||||
minitest
|
minitest
|
||||||
nokogiri (>= 1.6)
|
nokogiri (>= 1.6)
|
||||||
rails-html-sanitizer (1.7.1)
|
rails-html-sanitizer (1.6.2)
|
||||||
loofah (~> 2.25, >= 2.25.2)
|
loofah (~> 2.21)
|
||||||
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
|
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
|
||||||
rails-i18n (8.0.2)
|
rails-i18n (8.0.2)
|
||||||
i18n (>= 0.7, < 2)
|
i18n (>= 0.7, < 2)
|
||||||
railties (>= 8.0.0, < 9)
|
railties (>= 8.0.0, < 9)
|
||||||
railties (8.0.5.1)
|
railties (8.0.2)
|
||||||
actionpack (= 8.0.5.1)
|
actionpack (= 8.0.2)
|
||||||
activesupport (= 8.0.5.1)
|
activesupport (= 8.0.2)
|
||||||
irb (~> 1.13)
|
irb (~> 1.13)
|
||||||
rackup (>= 1.0.0)
|
rackup (>= 1.0.0)
|
||||||
rake (>= 12.2)
|
rake (>= 12.2)
|
||||||
thor (~> 1.0, >= 1.2.2)
|
thor (~> 1.0, >= 1.2.2)
|
||||||
tsort (>= 0.2)
|
|
||||||
zeitwerk (~> 2.6)
|
zeitwerk (~> 2.6)
|
||||||
rainbow (3.1.1)
|
rainbow (3.1.1)
|
||||||
rake (13.4.2)
|
rake (13.2.1)
|
||||||
rdoc (6.17.0)
|
rdoc (6.13.1)
|
||||||
erb
|
|
||||||
psych (>= 4.0.0)
|
psych (>= 4.0.0)
|
||||||
tsort
|
regexp_parser (2.10.0)
|
||||||
regexp_parser (2.13.1)
|
reline (0.6.1)
|
||||||
reline (0.7.0)
|
|
||||||
io-console (~> 0.5)
|
io-console (~> 0.5)
|
||||||
rexml (3.4.4)
|
rexml (3.4.1)
|
||||||
rouge (3.30.0)
|
rouge (3.30.0)
|
||||||
rspec-core (3.13.6)
|
rspec-core (3.13.6)
|
||||||
rspec-support (~> 3.13.0)
|
rspec-support (~> 3.13.0)
|
||||||
@@ -352,20 +345,20 @@ GEM
|
|||||||
rspec-support (3.13.6)
|
rspec-support (3.13.6)
|
||||||
rss (0.3.1)
|
rss (0.3.1)
|
||||||
rexml
|
rexml
|
||||||
rubocop (1.92.0)
|
rubocop (1.75.6)
|
||||||
json (>= 2.3)
|
json (~> 2.3)
|
||||||
language_server-protocol (~> 3.17.0.2)
|
language_server-protocol (~> 3.17.0.2)
|
||||||
lint_roller (~> 1.1.0)
|
lint_roller (~> 1.1.0)
|
||||||
parallel (>= 1.10)
|
parallel (~> 1.10)
|
||||||
parser (>= 3.3.0.2)
|
parser (>= 3.3.0.2)
|
||||||
rainbow (>= 2.2.2, < 4.0)
|
rainbow (>= 2.2.2, < 4.0)
|
||||||
regexp_parser (>= 2.11.3, < 3.0)
|
regexp_parser (>= 2.9.3, < 3.0)
|
||||||
rubocop-ast (>= 1.49.0, < 2.0)
|
rubocop-ast (>= 1.44.0, < 2.0)
|
||||||
ruby-progressbar (~> 1.7)
|
ruby-progressbar (~> 1.7)
|
||||||
unicode-display_width (>= 2.4.0, < 4.0)
|
unicode-display_width (>= 2.4.0, < 4.0)
|
||||||
rubocop-ast (1.50.0)
|
rubocop-ast (1.44.1)
|
||||||
parser (>= 3.3.7.2)
|
parser (>= 3.3.7.2)
|
||||||
prism (~> 1.7)
|
prism (~> 1.4)
|
||||||
rubocop-performance (1.25.0)
|
rubocop-performance (1.25.0)
|
||||||
lint_roller (~> 1.1)
|
lint_roller (~> 1.1)
|
||||||
rubocop (>= 1.75.0, < 2.0)
|
rubocop (>= 1.75.0, < 2.0)
|
||||||
@@ -384,23 +377,24 @@ GEM
|
|||||||
ruby-vips (2.2.3)
|
ruby-vips (2.2.3)
|
||||||
ffi (~> 1.12)
|
ffi (~> 1.12)
|
||||||
logger
|
logger
|
||||||
|
ruby2_keywords (0.0.5)
|
||||||
rugged (1.9.0)
|
rugged (1.9.0)
|
||||||
securerandom (0.4.1)
|
securerandom (0.4.1)
|
||||||
sinatra (4.2.1)
|
sinatra (4.1.1)
|
||||||
logger (>= 1.6.0)
|
logger (>= 1.6.0)
|
||||||
mustermann (~> 3.0)
|
mustermann (~> 3.0)
|
||||||
rack (>= 3.0.0, < 4)
|
rack (>= 3.0.0, < 4)
|
||||||
rack-protection (= 4.2.1)
|
rack-protection (= 4.1.1)
|
||||||
rack-session (>= 2.0.0, < 3)
|
rack-session (>= 2.0.0, < 3)
|
||||||
tilt (~> 2.0)
|
tilt (~> 2.0)
|
||||||
sinatra-contrib (4.2.1)
|
sinatra-contrib (4.1.1)
|
||||||
multi_json (>= 0.0.2)
|
multi_json (>= 0.0.2)
|
||||||
mustermann (~> 3.0)
|
mustermann (~> 3.0)
|
||||||
rack-protection (= 4.2.1)
|
rack-protection (= 4.1.1)
|
||||||
sinatra (= 4.2.1)
|
sinatra (= 4.1.1)
|
||||||
tilt (~> 2.0)
|
tilt (~> 2.0)
|
||||||
sprockets (4.4.1)
|
sprockets (4.2.2)
|
||||||
concurrent-ruby (~> 1.1)
|
concurrent-ruby (~> 1.0)
|
||||||
logger
|
logger
|
||||||
rack (>= 2.2.4, < 4)
|
rack (>= 2.2.4, < 4)
|
||||||
sprockets-helpers (1.4.0)
|
sprockets-helpers (1.4.0)
|
||||||
@@ -409,14 +403,14 @@ GEM
|
|||||||
actionpack (>= 6.1)
|
actionpack (>= 6.1)
|
||||||
activesupport (>= 6.1)
|
activesupport (>= 6.1)
|
||||||
sprockets (>= 3.0.0)
|
sprockets (>= 3.0.0)
|
||||||
sqlite3 (2.10.1-aarch64-linux-gnu)
|
sqlite3 (2.6.0-aarch64-linux-gnu)
|
||||||
sqlite3 (2.10.1-aarch64-linux-musl)
|
sqlite3 (2.6.0-aarch64-linux-musl)
|
||||||
sqlite3 (2.10.1-arm-linux-gnu)
|
sqlite3 (2.6.0-arm-linux-gnu)
|
||||||
sqlite3 (2.10.1-arm-linux-musl)
|
sqlite3 (2.6.0-arm-linux-musl)
|
||||||
sqlite3 (2.10.1-arm64-darwin)
|
sqlite3 (2.6.0-arm64-darwin)
|
||||||
sqlite3 (2.10.1-x86_64-darwin)
|
sqlite3 (2.6.0-x86_64-darwin)
|
||||||
sqlite3 (2.10.1-x86_64-linux-gnu)
|
sqlite3 (2.6.0-x86_64-linux-gnu)
|
||||||
sqlite3 (2.10.1-x86_64-linux-musl)
|
sqlite3 (2.6.0-x86_64-linux-musl)
|
||||||
sshkit (1.24.0)
|
sshkit (1.24.0)
|
||||||
base64
|
base64
|
||||||
logger
|
logger
|
||||||
@@ -424,19 +418,18 @@ GEM
|
|||||||
net-sftp (>= 2.1.2)
|
net-sftp (>= 2.1.2)
|
||||||
net-ssh (>= 2.8.0)
|
net-ssh (>= 2.8.0)
|
||||||
ostruct
|
ostruct
|
||||||
stringio (3.2.0)
|
stringio (3.1.7)
|
||||||
therubyrhino (2.1.2)
|
therubyrhino (2.1.2)
|
||||||
therubyrhino_jar (>= 1.7.4, < 1.7.9)
|
therubyrhino_jar (>= 1.7.4, < 1.7.9)
|
||||||
therubyrhino_jar (1.7.8)
|
therubyrhino_jar (1.7.8)
|
||||||
thor (1.5.0)
|
thor (1.3.2)
|
||||||
thruster (0.1.13)
|
thruster (0.1.13)
|
||||||
thruster (0.1.13-aarch64-linux)
|
thruster (0.1.13-aarch64-linux)
|
||||||
thruster (0.1.13-arm64-darwin)
|
thruster (0.1.13-arm64-darwin)
|
||||||
thruster (0.1.13-x86_64-darwin)
|
thruster (0.1.13-x86_64-darwin)
|
||||||
thruster (0.1.13-x86_64-linux)
|
thruster (0.1.13-x86_64-linux)
|
||||||
tilt (2.9.0)
|
tilt (2.6.0)
|
||||||
timeout (0.6.1)
|
timeout (0.4.3)
|
||||||
tsort (0.2.0)
|
|
||||||
twitter-text (1.14.7)
|
twitter-text (1.14.7)
|
||||||
unf (~> 0.1.0)
|
unf (~> 0.1.0)
|
||||||
tzinfo (2.0.6)
|
tzinfo (2.0.6)
|
||||||
@@ -447,16 +440,16 @@ GEM
|
|||||||
unicode-display_width (3.1.4)
|
unicode-display_width (3.1.4)
|
||||||
unicode-emoji (~> 4.0, >= 4.0.4)
|
unicode-emoji (~> 4.0, >= 4.0.4)
|
||||||
unicode-emoji (4.0.4)
|
unicode-emoji (4.0.4)
|
||||||
uri (1.1.2)
|
uri (1.0.3)
|
||||||
useragent (0.16.11)
|
useragent (0.16.11)
|
||||||
webrick (1.9.1)
|
webrick (1.9.1)
|
||||||
websocket-driver (0.8.2)
|
websocket-driver (0.7.7)
|
||||||
base64
|
base64
|
||||||
websocket-extensions (>= 0.1.0)
|
websocket-extensions (>= 0.1.0)
|
||||||
websocket-extensions (0.1.5)
|
websocket-extensions (0.1.5)
|
||||||
whenever (1.0.0)
|
whenever (1.0.0)
|
||||||
chronic (>= 0.6.3)
|
chronic (>= 0.6.3)
|
||||||
zeitwerk (2.8.3)
|
zeitwerk (2.7.2)
|
||||||
|
|
||||||
PLATFORMS
|
PLATFORMS
|
||||||
aarch64-linux
|
aarch64-linux
|
||||||
@@ -480,7 +473,6 @@ DEPENDENCIES
|
|||||||
factory_bot_rails
|
factory_bot_rails
|
||||||
gollum
|
gollum
|
||||||
image_processing (~> 1.14)
|
image_processing (~> 1.14)
|
||||||
json (~> 2.19.9)
|
|
||||||
jwt
|
jwt
|
||||||
kamal
|
kamal
|
||||||
mysql2 (~> 0.5.6)
|
mysql2 (~> 0.5.6)
|
||||||
|
|||||||
@@ -14,9 +14,8 @@ class PostVersionsController < ApplicationController
|
|||||||
|
|
||||||
offset = (page - 1) * limit
|
offset = (page - 1) * limit
|
||||||
|
|
||||||
q = HistoryAccess.visible(PostVersion.all)
|
q = HistoryAccess.visible(version_scope)
|
||||||
q = q.where('post_versions.post_id = ?', post_id) if post_id
|
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)))
|
if external_tag_id || (tag_id && !(Tag.exists?(id: tag_id)))
|
||||||
q = q.where('JSON_CONTAINS(post_versions.tags_json,' +
|
q = q.where('JSON_CONTAINS(post_versions.tags_json,' +
|
||||||
"JSON_OBJECT('external_tag_id', #{ external_tag_id || tag_id })) " +
|
"JSON_OBJECT('external_tag_id', #{ external_tag_id || tag_id })) " +
|
||||||
@@ -29,12 +28,9 @@ class PostVersionsController < ApplicationController
|
|||||||
|
|
||||||
count = q.except(:select, :order, :limit, :offset).count
|
count = q.except(:select, :order, :limit, :offset).count
|
||||||
|
|
||||||
ids = q.order(Arel.sql('post_versions.created_at DESC, post_versions.id DESC'))
|
versions = q.order(Arel.sql('post_versions.created_at DESC, post_versions.id DESC'))
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
.offset(offset)
|
.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) }
|
render json: { versions: serialise_versions(versions, locale.language_code), count:, population_count: HistoryAccess.population_count(PostVersion) }
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -37,10 +37,20 @@ class PostsController < ApplicationController
|
|||||||
|
|
||||||
offset = (page - 1) * limit
|
offset = (page - 1) * limit
|
||||||
|
|
||||||
needs_history_join = order[0] == 'updated_at' || updated_between.any?(&:present?)
|
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))'
|
||||||
|
|
||||||
q =
|
q =
|
||||||
filtered_posts(locale)
|
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,
|
.preload(:external_tags, :uploaded_user, :parents, :children,
|
||||||
post_tags: [:sections, { tag: [:deerjikists, :materials,
|
post_tags: [:sections, { tag: [:deerjikists, :materials,
|
||||||
:tag_names, :tag_descriptions] }])
|
:tag_names, :tag_descriptions] }])
|
||||||
@@ -56,25 +66,12 @@ class PostsController < ApplicationController
|
|||||||
end
|
end
|
||||||
q = q.where('posts.created_at >= ?', created_between[0]) if created_between[0]
|
q = q.where('posts.created_at >= ?', created_between[0]) if created_between[0]
|
||||||
q = q.where('posts.created_at <= ?', created_between[1]) if created_between[1]
|
q = q.where('posts.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]
|
if updated_between[0]
|
||||||
q = q.where("#{ updated_at_all_sql } >= ?", updated_between[0])
|
q = q.where("#{ updated_at_all_sql } >= ?", updated_between[0])
|
||||||
end
|
end
|
||||||
if updated_between[1]
|
if updated_between[1]
|
||||||
q = q.where("#{ updated_at_all_sql } <= ?", updated_between[1])
|
q = q.where("#{ updated_at_all_sql } <= ?", updated_between[1])
|
||||||
end
|
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 =
|
sort_sql =
|
||||||
case order[0]
|
case order[0]
|
||||||
@@ -91,20 +88,12 @@ class PostsController < ApplicationController
|
|||||||
.limit(limit)
|
.limit(limit)
|
||||||
.offset(offset)
|
.offset(offset)
|
||||||
.to_a
|
.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
|
|
||||||
|
|
||||||
count_scope = count_scope.except(:select, :order)
|
q = q.except(:select, :order)
|
||||||
|
|
||||||
render json: { posts: posts.map { |post|
|
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)
|
PostRepr.base(post, language_code: locale.language_code)
|
||||||
.merge('updated_at' => updated_at).tap do |json|
|
.merge(updated_at: post.updated_at_all).tap do |json|
|
||||||
json['thumbnail'] =
|
json['thumbnail'] =
|
||||||
if post.thumbnail.attached?
|
if post.thumbnail.attached?
|
||||||
rails_storage_proxy_url(post.thumbnail, only_path: false)
|
rails_storage_proxy_url(post.thumbnail, only_path: false)
|
||||||
@@ -112,7 +101,7 @@ class PostsController < ApplicationController
|
|||||||
nil
|
nil
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
}, count: count_scope.group_values.present? ? count_scope.count.size : count_scope.count }
|
}, count: q.group_values.present? ? q.count.size : q.count }
|
||||||
end
|
end
|
||||||
|
|
||||||
def random
|
def random
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
class Event < ApplicationRecord
|
||||||
|
has_many :post_versions
|
||||||
|
has_many :tag_versions
|
||||||
|
has_many :material_versions
|
||||||
|
has_many :tag_description_versions
|
||||||
|
has_many :tag_group_versions
|
||||||
|
|
||||||
|
belongs_to :created_by_user, class_name: 'User', optional: true
|
||||||
|
end
|
||||||
@@ -4,7 +4,7 @@ require 'digest'
|
|||||||
class HistoryAccess < ActiveSupport::CurrentAttributes
|
class HistoryAccess < ActiveSupport::CurrentAttributes
|
||||||
attribute :user
|
attribute :user
|
||||||
class Forbidden < StandardError; end
|
class Forbidden < StandardError; end
|
||||||
ARTIFACT_POLICY = 'counts-private-predicates-4'.freeze
|
ARTIFACT_POLICY = 'counts-masked-summary-1'.freeze
|
||||||
|
|
||||||
KEYS = { 'post_versions' => ['post_id'], 'tag_versions' => ['tag_id'],
|
KEYS = { 'post_versions' => ['post_id'], 'tag_versions' => ['tag_id'],
|
||||||
'nico_tag_versions' => ['tag_id'],
|
'nico_tag_versions' => ['tag_id'],
|
||||||
@@ -59,21 +59,20 @@ class HistoryAccess < ActiveSupport::CurrentAttributes
|
|||||||
end
|
end
|
||||||
|
|
||||||
def self.json_reference(table, key, document)
|
def self.json_reference(table, key, document)
|
||||||
# A scalar lookup avoids MySQL's semijoin rewrite losing the JSON row correlation.
|
"NOT EXISTS (SELECT 1 FROM JSON_TABLE(COALESCE(#{ document }, JSON_ARRAY()), " +
|
||||||
"(SELECT 1 FROM JSON_TABLE(COALESCE(#{ document }, JSON_ARRAY()), " +
|
|
||||||
"'$[*]' COLUMNS (reference_id BIGINT PATH '$' NULL ON EMPTY)) reference_entry " +
|
"'$[*]' COLUMNS (reference_id BIGINT PATH '$' NULL ON EMPTY)) reference_entry " +
|
||||||
"JOIN (#{ discarded_keys(table, key) }) blocked " +
|
"JOIN (#{ discarded_keys(table, key) }) blocked " +
|
||||||
"ON reference_entry.reference_id = blocked.#{ key } LIMIT 1) IS NULL"
|
"ON reference_entry.reference_id = blocked.#{ key })"
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.list_reference(table, key, document)
|
def self.list_reference(table, key, document)
|
||||||
# Preserve FIND_IN_SET's exact token semantics, including empty tokens.
|
# Preserve FIND_IN_SET's exact token semantics, including empty tokens.
|
||||||
array = "CONCAT('[', REPLACE(JSON_QUOTE(REPLACE(COALESCE(#{ document }, ''), " +
|
array = "CONCAT('[', REPLACE(JSON_QUOTE(REPLACE(COALESCE(#{ document }, ''), " +
|
||||||
"' ', ',')), ',', '\",\"'), ']')"
|
"' ', ',')), ',', '\",\"'), ']')"
|
||||||
"(SELECT 1 FROM JSON_TABLE(#{ array }, '$[*]' " +
|
"NOT EXISTS (SELECT 1 FROM JSON_TABLE(#{ array }, '$[*]' " +
|
||||||
"COLUMNS (reference_id VARCHAR(255) PATH '$')) reference_entry " +
|
"COLUMNS (reference_id VARCHAR(255) PATH '$')) reference_entry " +
|
||||||
"JOIN (#{ discarded_keys(table, key) }) blocked " +
|
"JOIN (#{ discarded_keys(table, key) }) blocked " +
|
||||||
"ON reference_entry.reference_id = CAST(blocked.#{ key } AS CHAR) LIMIT 1) IS NULL"
|
"ON reference_entry.reference_id = CAST(blocked.#{ key } AS CHAR))"
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.snapshot_reference(model, row, external: false)
|
def self.snapshot_reference(model, row, external: false)
|
||||||
@@ -92,8 +91,8 @@ class HistoryAccess < ActiveSupport::CurrentAttributes
|
|||||||
match += ' AND reference_version.version_no = tag_reference.version_no'
|
match += ' AND reference_version.version_no = tag_reference.version_no'
|
||||||
latest = ''
|
latest = ''
|
||||||
end
|
end
|
||||||
"(SELECT 1 FROM #{ source } JOIN #{ table } reference_version ON #{ match } " +
|
"NOT EXISTS (SELECT 1 FROM #{ source } JOIN #{ table } reference_version ON #{ match } " +
|
||||||
"WHERE NOT (#{ sql(model, 'reference_version') }) #{ latest } LIMIT 1) IS NULL"
|
"WHERE NOT (#{ sql(model, 'reference_version') }) #{ latest })"
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.reference_sql(table, row)
|
def self.reference_sql(table, row)
|
||||||
@@ -110,15 +109,11 @@ class HistoryAccess < ActiveSupport::CurrentAttributes
|
|||||||
# conservatively; never expose a private linked name through an external tag.
|
# conservatively; never expose a private linked name through an external tag.
|
||||||
names = "JSON_MERGE_PRESERVE(JSON_ARRAY(private_tag.name), " +
|
names = "JSON_MERGE_PRESERVE(JSON_ARRAY(private_tag.name), " +
|
||||||
"COALESCE(JSON_EXTRACT(private_tag.names, '$**.name'), JSON_ARRAY()))"
|
"COALESCE(JSON_EXTRACT(private_tag.names, '$**.name'), JSON_ARRAY()))"
|
||||||
# DISTINCT prevents merging this independent name set into the correlated scan.
|
["NOT EXISTS (SELECT 1 FROM tag_versions private_tag " +
|
||||||
# Keep byte-distinct names even when the column collation considers them equal.
|
|
||||||
private_names = 'SELECT DISTINCT private_name.name, ' +
|
|
||||||
'CAST(private_name.name AS BINARY) AS name_bytes FROM tag_versions private_tag ' +
|
|
||||||
"JOIN (#{ discarded_keys('tag_versions', 'tag_id') }) blocked " +
|
"JOIN (#{ discarded_keys('tag_versions', 'tag_id') }) blocked " +
|
||||||
'ON blocked.tag_id = private_tag.tag_id ' +
|
"ON blocked.tag_id = private_tag.tag_id " +
|
||||||
"JOIN JSON_TABLE(#{ names }, '$[*]' COLUMNS (name TEXT PATH '$')) private_name " +
|
"JOIN JSON_TABLE(#{ names }, '$[*]' COLUMNS (name TEXT PATH '$')) private_name " +
|
||||||
"WHERE private_name.name <> ''"
|
"WHERE private_name.name <> '' AND " +
|
||||||
["NOT EXISTS (SELECT 1 FROM (#{ private_names }) private_name WHERE " +
|
|
||||||
"LOCATE(CONCAT(' ', private_name.name, ' '), " +
|
"LOCATE(CONCAT(' ', private_name.name, ' '), " +
|
||||||
"CONCAT(' ', COALESCE(#{ row }.linked_tags, ''), ' ')) > 0)"]
|
"CONCAT(' ', COALESCE(#{ row }.linked_tags, ''), ' ')) > 0)"]
|
||||||
when 'tag_versions'
|
when 'tag_versions'
|
||||||
|
|||||||
@@ -11,7 +11,5 @@ class MaterialVersion < ApplicationRecord
|
|||||||
belongs_to :parent, class_name: 'Material', optional: true
|
belongs_to :parent, class_name: 'Material', optional: true
|
||||||
belongs_to :updated_by_user, class_name: 'User', optional: true
|
belongs_to :updated_by_user, class_name: 'User', optional: true
|
||||||
|
|
||||||
def export_paths_hash
|
def export_paths_hash = export_paths_json || { }
|
||||||
export_paths_json || {}
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ module VersionRecord
|
|||||||
|
|
||||||
belongs_to :created_by_user, class_name: 'User', optional: true
|
belongs_to :created_by_user, class_name: 'User', optional: true
|
||||||
|
|
||||||
|
belongs_to :event
|
||||||
|
|
||||||
enum :event_type, event_type_map, prefix: true, validate: true
|
enum :event_type, event_type_map, prefix: true, validate: true
|
||||||
|
|
||||||
validates :version_no, presence: true, numericality: { only_integer: true, greater_than: 0 }
|
validates :version_no, presence: true, numericality: { only_integer: true, greater_than: 0 }
|
||||||
|
|||||||
@@ -1,41 +1,12 @@
|
|||||||
class CountsAxes
|
class CountsAxes
|
||||||
MAX_AXES = 8
|
MAX_AXES = 8
|
||||||
MASKED_TEXT = '********'.freeze
|
|
||||||
MASKED_VALUE = '__counts_private__'.freeze
|
MASKED_VALUE = '__counts_private__'.freeze
|
||||||
PUBLIC_FIELDS = ['resource_id', 'version_no', 'event_type', 'user',
|
PUBLIC_FIELDS = ['created_at', 'event_type'].freeze
|
||||||
'created_at'].freeze
|
|
||||||
class TooManyAxes < ArgumentError; end
|
class TooManyAxes < ArgumentError; end
|
||||||
class PrivateCondition < ArgumentError; end
|
class PrivateCondition < ArgumentError; end
|
||||||
|
|
||||||
GRAINS = ['year', 'quarter', 'month', 'week', 'day', 'hour'].freeze
|
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)
|
def self.time_expression(expression, grain, timezone)
|
||||||
raise ArgumentError unless GRAINS.include?(grain)
|
raise ArgumentError unless GRAINS.include?(grain)
|
||||||
|
|
||||||
@@ -89,9 +60,13 @@ class CountsAxes
|
|||||||
@definition.fields[field] == :datetime ? self.class.time_expression(
|
@definition.fields[field] == :datetime ? self.class.time_expression(
|
||||||
expression, axis.fetch('grain', 'month'), @population.timezone) : expression
|
expression, axis.fetch('grain', 'month'), @population.timezone) : expression
|
||||||
end
|
end
|
||||||
if !HistoryAccess.admin? && self.class.private_field?(field)
|
if !HistoryAccess.admin? && !PUBLIC_FIELDS.include?(field)
|
||||||
expression = self.class.masked_sql(
|
visible = HistoryAccess.sql(@definition.model, 'v')
|
||||||
"CAST((#{ expression }) AS CHAR)", @definition, field)
|
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"
|
||||||
end
|
end
|
||||||
expression
|
expression
|
||||||
end
|
end
|
||||||
@@ -102,12 +77,10 @@ class CountsAxes
|
|||||||
selected = indexes.map { "#{ expressions[_1] } AS a#{ _1 }" }
|
selected = indexes.map { "#{ expressions[_1] } AS a#{ _1 }" }
|
||||||
identities = ['v.id']
|
identities = ['v.id']
|
||||||
if rows.any? { _1['field'] == 'tags' }
|
if rows.any? { _1['field'] == 'tags' }
|
||||||
identities << self.class.masked_sql(
|
# Raw identities stay inside COUNT; masked tag occurrences remain additive.
|
||||||
"COALESCE(CONCAT('internal:', tag_entry.tag_id), " +
|
identities << "COALESCE(CONCAT('internal:', tag_entry.tag_id), " +
|
||||||
"CONCAT('external:', tag_entry.external_tag_id))", @definition, 'tags')
|
"CONCAT('external:', tag_entry.external_tag_id))"
|
||||||
if rows.any? { _1['field'] == 'changed_field' }
|
identities << 'changed.field_key' if rows.any? { _1['field'] == 'changed_field' }
|
||||||
identities << self.class.masked_sql('changed.field_key', @definition, 'changed_field')
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
members = identities.each_index.map { "counts_member_#{ _1 }" }
|
members = identities.each_index.map { "counts_member_#{ _1 }" }
|
||||||
selected += identities.each_with_index.map { |expression, index|
|
selected += identities.each_with_index.map { |expression, index|
|
||||||
@@ -125,17 +98,8 @@ class CountsAxes
|
|||||||
def joins(fields = all.map { _1['field'] })
|
def joins(fields = all.map { _1['field'] })
|
||||||
result = ''
|
result = ''
|
||||||
if fields.include?('tags')
|
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
|
result += <<~SQL.squish
|
||||||
JOIN JSON_TABLE(#{ document }, '$[*]' COLUMNS (
|
JOIN JSON_TABLE(CAST(v.tags_json AS JSON), '$[*]' COLUMNS (
|
||||||
tag_id BIGINT PATH '$.tag_id' NULL ON EMPTY,
|
tag_id BIGINT PATH '$.tag_id' NULL ON EMPTY,
|
||||||
external_tag_id BIGINT PATH '$.external_tag_id' NULL ON EMPTY)) tag_entry
|
external_tag_id BIGINT PATH '$.external_tag_id' NULL ON EMPTY)) tag_entry
|
||||||
|
|
||||||
@@ -148,13 +112,6 @@ class CountsAxes
|
|||||||
condition = @definition.changes.map do |field|
|
condition = @definition.changes.map do |field|
|
||||||
"(changed.field_key = '#{ field }' AND #{ @population.changed_sql(field) })"
|
"(changed.field_key = '#{ field }' AND #{ @population.changed_sql(field) })"
|
||||||
end.join(' OR ')
|
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 })"
|
result += " JOIN (#{ fields }) changed ON (#{ condition })"
|
||||||
end
|
end
|
||||||
result
|
result
|
||||||
|
|||||||
@@ -82,32 +82,14 @@ class CountsCondition
|
|||||||
|
|
||||||
def apply(scope, entry)
|
def apply(scope, entry)
|
||||||
raise ArgumentError unless entry.is_a?(Hash)
|
raise ArgumentError unless entry.is_a?(Hash)
|
||||||
|
|
||||||
if entry['operator'] == 'axis_eq'
|
if entry['operator'] == 'axis_eq'
|
||||||
raise ArgumentError unless (entry.keys - ['field', 'operator', 'grain', 'value']).empty?
|
raise ArgumentError unless (entry.keys - ['field', 'operator', 'grain', 'value']).empty?
|
||||||
value = entry['value']
|
value = entry['value']
|
||||||
unless (value.nil? || value.is_a?(String) || value.is_a?(Numeric) ||
|
unless value.nil? || value.is_a?(String) || value.is_a?(Numeric) || [true, false].include?(value)
|
||||||
[true, false].include?(value))
|
|
||||||
raise ArgumentError
|
raise ArgumentError
|
||||||
end
|
end
|
||||||
raise ArgumentError if value.is_a?(String) && value.bytesize > 8192
|
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)
|
return @population.axis_condition(scope, entry)
|
||||||
end
|
end
|
||||||
allowed = ['field', 'operator', 'value', 'end', 'blank', 'input', 'values']
|
allowed = ['field', 'operator', 'value', 'end', 'blank', 'input', 'values']
|
||||||
@@ -129,13 +111,6 @@ class CountsCondition
|
|||||||
|
|
||||||
source, expression = source_for(field[:key])
|
source, expression = source_for(field[:key])
|
||||||
type = field[:type]
|
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)
|
blank = blank_sql(expression, type)
|
||||||
if entry['blank']
|
if entry['blank']
|
||||||
sql = if source && field[:collection]
|
sql = if source && field[:collection]
|
||||||
@@ -144,7 +119,6 @@ class CountsCondition
|
|||||||
"NOT EXISTS (SELECT 1 FROM #{ source } WHERE NOT (#{ blank }))"
|
"NOT EXISTS (SELECT 1 FROM #{ source } WHERE NOT (#{ blank }))"
|
||||||
else blank
|
else blank
|
||||||
end
|
end
|
||||||
scope = @population.condition_scope(scope, field[:key])
|
|
||||||
return scope.where(Arel.sql(sql))
|
return scope.where(Arel.sql(sql))
|
||||||
end
|
end
|
||||||
if ['in', 'not_in', 'any', 'all', 'none'].include?(entry['operator'])
|
if ['in', 'not_in', 'any', 'all', 'none'].include?(entry['operator'])
|
||||||
@@ -157,7 +131,6 @@ class CountsCondition
|
|||||||
validate_enum!(field[:key], parsed.first) if type == :enum
|
validate_enum!(field[:key], parsed.first) if type == :enum
|
||||||
operator = entry['operator']
|
operator = entry['operator']
|
||||||
if type == :interval
|
if type == :interval
|
||||||
scope = @population.condition_scope(scope, field[:key])
|
|
||||||
return interval(scope, source, expression, parsed)
|
return interval(scope, source, expression, parsed)
|
||||||
end
|
end
|
||||||
clauses = []
|
clauses = []
|
||||||
@@ -185,7 +158,6 @@ class CountsCondition
|
|||||||
elsif operator == 'ne'
|
elsif operator == 'ne'
|
||||||
predicate = "(NOT (#{ predicate }) OR #{ blank })"
|
predicate = "(NOT (#{ predicate }) OR #{ blank })"
|
||||||
end
|
end
|
||||||
scope = @population.condition_scope(scope, field[:key])
|
|
||||||
scope.where(predicate, *binds)
|
scope.where(predicate, *binds)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -196,13 +168,6 @@ class CountsCondition
|
|||||||
|
|
||||||
values = entry['values']
|
values = entry['values']
|
||||||
raise ArgumentError unless values.is_a?(Array) && values.length <= 100
|
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 != '' }
|
raise ArgumentError if [entry['value'], entry['end']].any? { !_1.nil? && _1 != '' }
|
||||||
|
|
||||||
parsed = values.map do |value|
|
parsed = values.map do |value|
|
||||||
@@ -226,10 +191,7 @@ class CountsCondition
|
|||||||
|
|
||||||
operator = entry['operator']
|
operator = entry['operator']
|
||||||
if parsed.empty?
|
if parsed.empty?
|
||||||
return scope.none if ['in', 'any'].include?(operator)
|
return ['in', 'any'].include?(operator) ? scope.none : scope
|
||||||
return scope if entry['values'].empty?
|
|
||||||
|
|
||||||
return @population.condition_scope(scope, field[:key])
|
|
||||||
end
|
end
|
||||||
source, expression = source_for(field[:key])
|
source, expression = source_for(field[:key])
|
||||||
if field[:set_type] == 'tag_reference'
|
if field[:set_type] == 'tag_reference'
|
||||||
@@ -250,7 +212,6 @@ class CountsCondition
|
|||||||
predicate = source ? "NOT #{ predicate }" :
|
predicate = source ? "NOT #{ predicate }" :
|
||||||
"(NOT #{ predicate } OR #{ blank_sql(expression, field[:type]) })"
|
"(NOT #{ predicate } OR #{ blank_sql(expression, field[:type]) })"
|
||||||
end
|
end
|
||||||
scope = @population.condition_scope(scope, field[:key])
|
|
||||||
scope.where(predicate, *parsed)
|
scope.where(predicate, *parsed)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -70,13 +70,6 @@ class CountsPopulation
|
|||||||
@authorisation_flg ? HistoryAccess.sql(model, row) : 'TRUE'
|
@authorisation_flg ? HistoryAccess.sql(model, row) : 'TRUE'
|
||||||
end
|
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)
|
def axis_condition(scope, entry)
|
||||||
raise CountsAxes::PrivateCondition if entry['value'] == CountsAxes::MASKED_VALUE
|
raise CountsAxes::PrivateCondition if entry['value'] == CountsAxes::MASKED_VALUE
|
||||||
|
|
||||||
@@ -216,7 +209,6 @@ class CountsPopulation
|
|||||||
def apply_tags
|
def apply_tags
|
||||||
raise ArgumentError unless definition.resource == 'posts'
|
raise ArgumentError unless definition.resource == 'posts'
|
||||||
|
|
||||||
@scope = condition_scope(@scope, 'tags')
|
|
||||||
if params['tag'].present?
|
if params['tag'].present?
|
||||||
@scope = @scope.where(snapshot_membership(
|
@scope = @scope.where(snapshot_membership(
|
||||||
"#{ params.fetch('kind', 'internal') }:#{ params['tag'] }"))
|
"#{ params.fetch('kind', 'internal') }:#{ params['tag'] }"))
|
||||||
|
|||||||
@@ -253,7 +253,7 @@ class CountsTableExport
|
|||||||
end
|
end
|
||||||
|
|
||||||
def summary_value(value, index, page)
|
def summary_value(value, index, page)
|
||||||
return CountsAxes::MASKED_TEXT if value == CountsAxes::MASKED_VALUE
|
return '********' if value == CountsAxes::MASKED_VALUE
|
||||||
|
|
||||||
field = @query.axes.all.fetch(index).fetch('field')
|
field = @query.axes.all.fetch(index).fetch('field')
|
||||||
return @labels.fetch('bot', '') if field == 'user' && value.nil?
|
return @labels.fetch('bot', '') if field == 'user' && value.nil?
|
||||||
|
|||||||
@@ -220,11 +220,19 @@ class CountsTableQuery
|
|||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
# Private predicates require visibility independently of their operator.
|
# Public timeline predicates may include hidden records. Private predicates must
|
||||||
# Ordering/measure state must not select a server-only masked coordinate.
|
# never become a count oracle, even when the selected axes themselves are public.
|
||||||
def validate_summary_conditions!
|
def validate_summary_conditions!
|
||||||
return if mode != 'summary' || HistoryAccess.admin?
|
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|
|
['column_order', 'measures'].each do |key|
|
||||||
# Coordinates are compared only to already masked expressions, never raw
|
# Coordinates are compared only to already masked expressions, never raw
|
||||||
# values. Public coordinates remain sortable; a guessed private ID cannot match.
|
# values. Public coordinates remain sortable; a guessed private ID cannot match.
|
||||||
|
|||||||
@@ -1,20 +1,23 @@
|
|||||||
class MaterialVersionRecorder < VersionRecorder
|
class MaterialVersionRecorder < VersionRecorder
|
||||||
EVENT_TYPES = ['create', 'update', 'discard', 'restore'].freeze
|
EVENT_TYPES = ['create', 'update', 'discard', 'restore'].freeze
|
||||||
|
|
||||||
def self.record! material:, event_type:, created_by_user:, file_snapshot: nil
|
def self.record! material:, event_type:, event:, created_by_user:, file_snapshot: nil
|
||||||
new(material:, event_type:, created_by_user:, file_snapshot:).record!
|
new(material:, event_type:, event:, created_by_user:, file_snapshot:).record!
|
||||||
end
|
end
|
||||||
|
|
||||||
def initialize material:, event_type:, created_by_user:, file_snapshot: nil
|
def initialize material:, event_type:, event:, created_by_user:, file_snapshot: nil
|
||||||
@file_snapshot = file_snapshot
|
@file_snapshot = file_snapshot
|
||||||
|
|
||||||
super(record: material, event_type:, created_by_user:)
|
super(record: material, event_type:, event:, created_by_user:)
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.ensure_snapshot! material, created_by_user:
|
def self.ensure_snapshot! material, created_by_user:
|
||||||
return if material.material_versions.exists?
|
return if material.material_versions.exists?
|
||||||
|
|
||||||
record!(material:, event_type: :create,
|
event = Event.create!(event_type: 'materials.create',
|
||||||
|
created_by_user:,
|
||||||
|
created_at: material.created_at)
|
||||||
|
record!(material:, event_type: :create, event:,
|
||||||
created_by_user: material.created_by_user || created_by_user)
|
created_by_user: material.created_by_user || created_by_user)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,19 @@
|
|||||||
class PostVersionRecorder < VersionRecorder
|
class PostVersionRecorder < VersionRecorder
|
||||||
def self.record! post:, event_type:, created_by_user:
|
def self.record! post:, event_type:, event:, created_by_user:
|
||||||
new(post:, event_type:, created_by_user:).record!
|
new(post:, event_type:, event:, created_by_user:).record!
|
||||||
end
|
end
|
||||||
|
|
||||||
def initialize post:, event_type:, created_by_user:
|
def initialize post:, event_type:, event:, created_by_user:
|
||||||
super(record: post, event_type:, created_by_user:)
|
super(record: post, event_type:, event:, created_by_user:)
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.ensure_snapshot! post, created_by_user:
|
def self.ensure_snapshot! post, created_by_user:
|
||||||
return if post.post_versions.exists?
|
return if post.post_versions.exists?
|
||||||
|
|
||||||
record!(post:, event_type: :create, created_by_user:)
|
event = Event.create!(event_type: 'posts.create',
|
||||||
|
created_by_user:,
|
||||||
|
created_at: post.created_at)
|
||||||
|
record!(post:, event_type: :create, event:, created_by_user:)
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
class TagVersionRecorder < VersionRecorder
|
class TagVersionRecorder < VersionRecorder
|
||||||
def self.record! tag:, event_type:, created_by_user:, **_options
|
def self.record! tag:, event_type:, event:, created_by_user:, **_options
|
||||||
Tag.transaction do
|
Tag.transaction do
|
||||||
SharedResourceIdAllocator.lock_space!(Tag, 'tags')
|
SharedResourceIdAllocator.lock_space!(Tag, 'tags')
|
||||||
new(tag:, event_type:, created_by_user:).record!
|
new(tag:, event_type:, event:, created_by_user:).record!
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def initialize tag:, event_type:, created_by_user:
|
def initialize tag:, event_type:, event:, created_by_user:
|
||||||
super(record: tag, event_type:, created_by_user:)
|
super(record: tag, event_type:, event:, created_by_user:)
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
class VersionRecorder
|
class VersionRecorder
|
||||||
EVENT_TYPES = ['create', 'update', 'discard', 'restore'].freeze
|
EVENT_TYPES = ['create', 'update', 'discard', 'restore'].freeze
|
||||||
|
|
||||||
def initialize record:, event_type:, created_by_user:
|
def initialize record:, event_type:, event:, created_by_user:
|
||||||
@record = record
|
@record = record
|
||||||
@event_type = event_type.to_s
|
@event_type = event_type.to_s
|
||||||
|
@event = event
|
||||||
@created_by_user = created_by_user
|
@created_by_user = created_by_user
|
||||||
|
|
||||||
validate_event_type!
|
validate_event_type!
|
||||||
@@ -42,8 +43,9 @@ class VersionRecorder
|
|||||||
def base_attributes latest
|
def base_attributes latest
|
||||||
{ version_no: (latest&.version_no || 0) + 1,
|
{ version_no: (latest&.version_no || 0) + 1,
|
||||||
event_type: @event_type,
|
event_type: @event_type,
|
||||||
created_at: Time.current,
|
event: @event,
|
||||||
created_by_user: @created_by_user }
|
created_by_user: @created_by_user,
|
||||||
|
created_at: Time.current }
|
||||||
end
|
end
|
||||||
|
|
||||||
def update_record_version_no! version_no
|
def update_record_version_no! version_no
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
class CreateEvents < ActiveRecord::Migration[8.0]
|
||||||
|
def change
|
||||||
|
create_table :events do |t|
|
||||||
|
t.string :event_type, null: false, index: true
|
||||||
|
t.text :reason
|
||||||
|
t.references :reverted_from_event, foreign_key: { to_table: :events }
|
||||||
|
t.json :metadata
|
||||||
|
t.binary :ip_address, limit: 16, index: true
|
||||||
|
t.references :created_by_user, index: true, foreign_key: false
|
||||||
|
t.datetime :created_at, null: false, index: true
|
||||||
|
end
|
||||||
|
|
||||||
|
add_reference :post_versions, :event, foreign_key: true, after: :original_created_before
|
||||||
|
add_reference :tag_versions, :event, foreign_key: true, after: :deprecated_at
|
||||||
|
add_reference :material_versions, :event, foreign_key: true
|
||||||
|
add_reference :tag_description_versions, :event, foreign_key: true, after: :source_version_no
|
||||||
|
add_reference :tag_group_versions, :event, foreign_key: true, after: :placements
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
class BackfillEventsFromExistingVersions < ActiveRecord::Migration[8.0]
|
||||||
|
class MigrationEvent < ActiveRecord::Base
|
||||||
|
self.table_name = 'events'
|
||||||
|
end
|
||||||
|
|
||||||
|
class MigrationPostVersion < ActiveRecord::Base
|
||||||
|
self.table_name = 'post_versions'
|
||||||
|
end
|
||||||
|
|
||||||
|
class MigrationTagVersion < ActiveRecord::Base
|
||||||
|
self.table_name = 'tag_versions'
|
||||||
|
end
|
||||||
|
|
||||||
|
class MigrationMaterialVersion < ActiveRecord::Base
|
||||||
|
self.table_name = 'material_versions'
|
||||||
|
end
|
||||||
|
|
||||||
|
class MigrationTagDescriptionVersion < ActiveRecord::Base
|
||||||
|
self.table_name = 'tag_description_versions'
|
||||||
|
end
|
||||||
|
|
||||||
|
class MigrationTagGroupVersion < ActiveRecord::Base
|
||||||
|
self.table_name = 'tag_group_versions'
|
||||||
|
end
|
||||||
|
|
||||||
|
class MigrationLocale < ActiveRecord::Base
|
||||||
|
self.table_name = 'locales'
|
||||||
|
end
|
||||||
|
|
||||||
|
def up
|
||||||
|
# create → posts.create
|
||||||
|
# update → posts.update
|
||||||
|
raise if MigrationPostVersion.where.not(event_type: ['create', 'update']).exists?
|
||||||
|
|
||||||
|
# create → materials.create
|
||||||
|
# update → materials.update
|
||||||
|
if MigrationMaterialVersion.where.not(event_type: ['create', 'update']).exists?
|
||||||
|
raise
|
||||||
|
end
|
||||||
|
|
||||||
|
# create → posts.create / posts.update / materials.create / materials.update / unknown
|
||||||
|
# update → tags.update / locales.create
|
||||||
|
# discard → unknown
|
||||||
|
if MigrationTagVersion.where.not(event_type: ['create', 'update', 'discard']).exists?
|
||||||
|
raise
|
||||||
|
end
|
||||||
|
|
||||||
|
# create → tag_descriptions.create
|
||||||
|
# update → tag_descriptions.update
|
||||||
|
if MigrationTagDescriptionVersion.where.not(event_type: ['create', 'update']).exists?
|
||||||
|
raise
|
||||||
|
end
|
||||||
|
|
||||||
|
# create → tag_groups.create
|
||||||
|
# update → tag_groups.update
|
||||||
|
if MigrationTagGroupVersion.where.not(event_type: ['create', 'update']).exists?
|
||||||
|
raise
|
||||||
|
end
|
||||||
|
|
||||||
|
# post_versions の Backfill
|
||||||
|
MigrationPostVersion.find_each do |pv|
|
||||||
|
ev = MigrationEvent.create!(event_type: "posts.#{ pv.event_type }",
|
||||||
|
reason: nil,
|
||||||
|
reverted_from_event_id: nil,
|
||||||
|
metadata: nil,
|
||||||
|
ip_address: nil,
|
||||||
|
created_by_user_id: pv.created_by_user_id,
|
||||||
|
created_at: pv.created_at)
|
||||||
|
pv.update_columns(event_id: ev.id)
|
||||||
|
end
|
||||||
|
|
||||||
|
# material_versions の Backfill
|
||||||
|
MigrationMaterialVersion.find_each do |m|
|
||||||
|
ev = MigrationEvent.create!(event_type: "materials.#{ m.event_type }",
|
||||||
|
reason: nil,
|
||||||
|
reverted_from_event_id: nil,
|
||||||
|
metadata: nil,
|
||||||
|
ip_address: nil,
|
||||||
|
created_by_user_id: m.created_by_user_id,
|
||||||
|
created_at: m.created_at)
|
||||||
|
m.update_columns(event_id: ev.id)
|
||||||
|
end
|
||||||
|
|
||||||
|
# ロカール追加に伴ふ該当語言のタグ名称追加異動のみ分離
|
||||||
|
ids_cause_of_locale_creation = []
|
||||||
|
MigrationLocale.where('created_at > ?', MigrationLocale.minimum(:created_at)).find_each do |l|
|
||||||
|
ev = MigrationEvent.create!(event_type: 'locales.create',
|
||||||
|
reason: nil,
|
||||||
|
reverted_from_event_id: nil,
|
||||||
|
metadata: nil,
|
||||||
|
ip_address: nil,
|
||||||
|
created_by_user_id: nil,
|
||||||
|
created_at: l.created_at)
|
||||||
|
|
||||||
|
MigrationTagVersion
|
||||||
|
.where('created_at > ?', l.created_at)
|
||||||
|
.where(event_type: 'update', event_id: nil)
|
||||||
|
.find_each do |t|
|
||||||
|
next unless t.names.key?(l.language_code)
|
||||||
|
|
||||||
|
prev = MigrationTagVersion.find_by(tag_id: t.tag_id, version_no: t.version_no - 1)
|
||||||
|
next if prev.nil? || prev.names.key?(l.language_code)
|
||||||
|
|
||||||
|
t.update_columns(event_id: ev.id)
|
||||||
|
|
||||||
|
ids_cause_of_locale_creation << t.id
|
||||||
|
end
|
||||||
|
|
||||||
|
ev.destroy! unless MigrationTagVersion.where(event_id: ev.id).exists?
|
||||||
|
end
|
||||||
|
|
||||||
|
# tag_versions の Backfill
|
||||||
|
MigrationTagVersion.where.not(id: ids_cause_of_locale_creation).find_each do |t|
|
||||||
|
ev =
|
||||||
|
case t.event_type
|
||||||
|
when 'create'
|
||||||
|
post =
|
||||||
|
MigrationPostVersion
|
||||||
|
.where('JSON_CONTAINS(tags_json, ?) <> 0', { tag_id: t.tag_id }.to_json)
|
||||||
|
.find_by(created_at: MigrationPostVersion
|
||||||
|
.where('JSON_CONTAINS(tags_json, ?) <> 0',
|
||||||
|
{ tag_id: t.tag_id }.to_json)
|
||||||
|
.minimum(:created_at))
|
||||||
|
material =
|
||||||
|
MigrationMaterialVersion
|
||||||
|
.where(tag_id: t.tag_id)
|
||||||
|
.find_by(created_at: MigrationMaterialVersion
|
||||||
|
.where(tag_id: t.tag_id)
|
||||||
|
.minimum(:created_at))
|
||||||
|
if post
|
||||||
|
if material
|
||||||
|
if post.created_at < material.created_at
|
||||||
|
MigrationEvent.find(post.event_id)
|
||||||
|
else
|
||||||
|
MigrationEvent.find(material.event_id)
|
||||||
|
end
|
||||||
|
else
|
||||||
|
MigrationEvent.find(post.event_id)
|
||||||
|
end
|
||||||
|
else
|
||||||
|
if material
|
||||||
|
MigrationEvent.find(material.event_id)
|
||||||
|
else
|
||||||
|
MigrationEvent.create!(event_type: 'unknown',
|
||||||
|
reason: nil,
|
||||||
|
reverted_from_event_id: nil,
|
||||||
|
metadata: nil,
|
||||||
|
ip_address: nil,
|
||||||
|
created_by_user_id: t.created_by_user_id,
|
||||||
|
created_at: t.created_at)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
when 'update'
|
||||||
|
MigrationEvent.create!(event_type: 'tags.update',
|
||||||
|
reason: nil,
|
||||||
|
reverted_from_event_id: nil,
|
||||||
|
metadata: nil,
|
||||||
|
ip_address: nil,
|
||||||
|
created_by_user_id: t.created_by_user_id,
|
||||||
|
created_at: t.created_at)
|
||||||
|
when 'discard'
|
||||||
|
MigrationEvent.create!(event_type: 'unknown',
|
||||||
|
reason: nil,
|
||||||
|
reverted_from_event_id: nil,
|
||||||
|
metadata: nil,
|
||||||
|
ip_address: nil,
|
||||||
|
created_by_user_id: t.created_by_user_id,
|
||||||
|
created_at: t.created_at)
|
||||||
|
end
|
||||||
|
t.update_columns(event_id: ev.id)
|
||||||
|
end
|
||||||
|
|
||||||
|
# tag_description_versions の Backfill
|
||||||
|
MigrationTagDescriptionVersion.find_each do |td|
|
||||||
|
ev = MigrationEvent.create!(event_type: "tag_descriptions.#{ td.event_type }",
|
||||||
|
reason: nil,
|
||||||
|
reverted_from_event_id: nil,
|
||||||
|
metadata: nil,
|
||||||
|
ip_address: nil,
|
||||||
|
created_by_user_id: td.created_by_user_id,
|
||||||
|
created_at: td.created_at)
|
||||||
|
td.update_columns(event_id: ev.id)
|
||||||
|
end
|
||||||
|
|
||||||
|
# tag_group_versions の Backfill
|
||||||
|
MigrationTagGroupVersion.find_each do |tg|
|
||||||
|
ev = MigrationEvent.create!(event_type: "tag_groups.#{ tg.event_type }",
|
||||||
|
reason: nil,
|
||||||
|
reverted_from_event_id: nil,
|
||||||
|
metadata: nil,
|
||||||
|
ip_address: nil,
|
||||||
|
created_by_user_id: tg.created_by_user_id,
|
||||||
|
created_at: tg.created_at)
|
||||||
|
tg.update_columns(event_id: ev.id)
|
||||||
|
end
|
||||||
|
|
||||||
|
change_column_null :post_versions, :event_id, false
|
||||||
|
change_column_null :tag_versions, :event_id, false
|
||||||
|
change_column_null :material_versions, :event_id, false
|
||||||
|
change_column_null :tag_description_versions, :event_id, false
|
||||||
|
change_column_null :tag_group_versions, :event_id, false
|
||||||
|
end
|
||||||
|
|
||||||
|
def down
|
||||||
|
raise ActiveRecord::IrreversibleMigration, '戻せません.'
|
||||||
|
end
|
||||||
|
end
|
||||||
生成ファイル
+32
-1
@@ -10,7 +10,7 @@
|
|||||||
#
|
#
|
||||||
# It's strongly recommended that you check this file into your version control system.
|
# It's strongly recommended that you check this file into your version control system.
|
||||||
|
|
||||||
ActiveRecord::Schema[8.0].define(version: 2026_10_15_010000) do
|
ActiveRecord::Schema[8.0].define(version: 2026_11_02_010000) do
|
||||||
create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||||
t.string "name", null: false
|
t.string "name", null: false
|
||||||
t.string "record_type", null: false
|
t.string "record_type", null: false
|
||||||
@@ -48,6 +48,21 @@ ActiveRecord::Schema[8.0].define(version: 2026_10_15_010000) do
|
|||||||
t.index ["tag_id"], name: "index_deerjikists_on_tag_id"
|
t.index ["tag_id"], name: "index_deerjikists_on_tag_id"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
create_table "events", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||||
|
t.string "event_type", null: false
|
||||||
|
t.text "reason"
|
||||||
|
t.bigint "reverted_from_event_id"
|
||||||
|
t.json "metadata"
|
||||||
|
t.binary "ip_address", limit: 16
|
||||||
|
t.bigint "created_by_user_id"
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.index ["created_at"], name: "index_events_on_created_at"
|
||||||
|
t.index ["created_by_user_id"], name: "index_events_on_created_by_user_id"
|
||||||
|
t.index ["event_type"], name: "index_events_on_event_type"
|
||||||
|
t.index ["ip_address"], name: "index_events_on_ip_address"
|
||||||
|
t.index ["reverted_from_event_id"], name: "index_events_on_reverted_from_event_id"
|
||||||
|
end
|
||||||
|
|
||||||
create_table "external_tags", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
create_table "external_tags", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||||
t.string "platform", limit: 16, null: false
|
t.string "platform", limit: 16, null: false
|
||||||
t.string "name", null: false
|
t.string "name", null: false
|
||||||
@@ -250,8 +265,10 @@ ActiveRecord::Schema[8.0].define(version: 2026_10_15_010000) do
|
|||||||
t.string "source_path"
|
t.string "source_path"
|
||||||
t.string "source_file_id"
|
t.string "source_file_id"
|
||||||
t.string "normalized_source_key"
|
t.string "normalized_source_key"
|
||||||
|
t.bigint "event_id", null: false
|
||||||
t.index ["created_by_user_id"], name: "index_material_versions_on_created_by_user_id"
|
t.index ["created_by_user_id"], name: "index_material_versions_on_created_by_user_id"
|
||||||
t.index ["discarded_at"], name: "index_material_versions_on_discarded_at"
|
t.index ["discarded_at"], name: "index_material_versions_on_discarded_at"
|
||||||
|
t.index ["event_id"], name: "index_material_versions_on_event_id"
|
||||||
t.index ["file_blob_id"], name: "index_material_versions_on_file_blob_id"
|
t.index ["file_blob_id"], name: "index_material_versions_on_file_blob_id"
|
||||||
t.index ["material_id", "version_no"], name: "index_material_versions_on_material_id_and_version_no", unique: true
|
t.index ["material_id", "version_no"], name: "index_material_versions_on_material_id_and_version_no", unique: true
|
||||||
t.index ["material_id"], name: "index_material_versions_on_material_id"
|
t.index ["material_id"], name: "index_material_versions_on_material_id"
|
||||||
@@ -380,10 +397,12 @@ ActiveRecord::Schema[8.0].define(version: 2026_10_15_010000) do
|
|||||||
t.text "parent_post_ids", null: false
|
t.text "parent_post_ids", null: false
|
||||||
t.datetime "original_created_from"
|
t.datetime "original_created_from"
|
||||||
t.datetime "original_created_before"
|
t.datetime "original_created_before"
|
||||||
|
t.bigint "event_id", null: false
|
||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
t.bigint "created_by_user_id"
|
t.bigint "created_by_user_id"
|
||||||
t.integer "video_ms"
|
t.integer "video_ms"
|
||||||
t.index ["created_by_user_id"], name: "index_post_versions_on_created_by_user_id"
|
t.index ["created_by_user_id"], name: "index_post_versions_on_created_by_user_id"
|
||||||
|
t.index ["event_id"], name: "index_post_versions_on_event_id"
|
||||||
t.index ["post_id", "version_no"], name: "index_post_versions_on_post_id_and_version_no", unique: true
|
t.index ["post_id", "version_no"], name: "index_post_versions_on_post_id_and_version_no", unique: true
|
||||||
t.index ["post_id"], name: "index_post_versions_on_post_id"
|
t.index ["post_id"], name: "index_post_versions_on_post_id"
|
||||||
t.index ["video_ms", "post_id"], name: "idx_post_versions_video_ms_post_id"
|
t.index ["video_ms", "post_id"], name: "idx_post_versions_video_ms_post_id"
|
||||||
@@ -448,9 +467,11 @@ ActiveRecord::Schema[8.0].define(version: 2026_10_15_010000) do
|
|||||||
t.text "body", null: false
|
t.text "body", null: false
|
||||||
t.string "source_language_code", limit: 16
|
t.string "source_language_code", limit: 16
|
||||||
t.integer "source_version_no"
|
t.integer "source_version_no"
|
||||||
|
t.bigint "event_id", null: false
|
||||||
t.bigint "created_by_user_id"
|
t.bigint "created_by_user_id"
|
||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
t.index ["created_by_user_id"], name: "index_tag_description_versions_on_created_by_user_id"
|
t.index ["created_by_user_id"], name: "index_tag_description_versions_on_created_by_user_id"
|
||||||
|
t.index ["event_id"], name: "index_tag_description_versions_on_event_id"
|
||||||
t.index ["tag_id", "language_code", "version_no"], name: "idx_on_tag_id_language_code_version_no_ced77e57e6", unique: true
|
t.index ["tag_id", "language_code", "version_no"], name: "idx_on_tag_id_language_code_version_no_ced77e57e6", unique: true
|
||||||
t.index ["tag_id", "source_language_code", "source_version_no"], name: "fk_rails_4f13094dbc"
|
t.index ["tag_id", "source_language_code", "source_version_no"], name: "fk_rails_4f13094dbc"
|
||||||
t.index ["tag_id"], name: "index_tag_description_versions_on_tag_id"
|
t.index ["tag_id"], name: "index_tag_description_versions_on_tag_id"
|
||||||
@@ -533,9 +554,11 @@ ActiveRecord::Schema[8.0].define(version: 2026_10_15_010000) do
|
|||||||
t.text "description"
|
t.text "description"
|
||||||
t.json "members", null: false
|
t.json "members", null: false
|
||||||
t.json "placements", null: false
|
t.json "placements", null: false
|
||||||
|
t.bigint "event_id", null: false
|
||||||
t.bigint "created_by_user_id"
|
t.bigint "created_by_user_id"
|
||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
t.index ["created_by_user_id"], name: "index_tag_group_versions_on_created_by_user_id"
|
t.index ["created_by_user_id"], name: "index_tag_group_versions_on_created_by_user_id"
|
||||||
|
t.index ["event_id"], name: "index_tag_group_versions_on_event_id"
|
||||||
t.index ["tag_group_id", "version_no"], name: "index_tag_group_versions_on_tag_group_id_and_version_no", unique: true
|
t.index ["tag_group_id", "version_no"], name: "index_tag_group_versions_on_tag_group_id_and_version_no", unique: true
|
||||||
t.index ["tag_group_id"], name: "index_tag_group_versions_on_tag_group_id"
|
t.index ["tag_group_id"], name: "index_tag_group_versions_on_tag_group_id"
|
||||||
t.check_constraint "`version_no` > 0", name: "chk_tag_group_versions_version_no_positive"
|
t.check_constraint "`version_no` > 0", name: "chk_tag_group_versions_version_no_positive"
|
||||||
@@ -611,11 +634,13 @@ ActiveRecord::Schema[8.0].define(version: 2026_10_15_010000) do
|
|||||||
t.text "aliases", null: false
|
t.text "aliases", null: false
|
||||||
t.text "parent_tag_ids", null: false
|
t.text "parent_tag_ids", null: false
|
||||||
t.datetime "deprecated_at"
|
t.datetime "deprecated_at"
|
||||||
|
t.bigint "event_id", null: false
|
||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
t.bigint "created_by_user_id"
|
t.bigint "created_by_user_id"
|
||||||
t.json "names", null: false
|
t.json "names", null: false
|
||||||
t.index ["created_at"], name: "index_tag_versions_on_created_at"
|
t.index ["created_at"], name: "index_tag_versions_on_created_at"
|
||||||
t.index ["created_by_user_id", "created_at"], name: "index_tag_versions_on_created_by_user_id_and_created_at", order: { created_at: :desc }
|
t.index ["created_by_user_id", "created_at"], name: "index_tag_versions_on_created_by_user_id_and_created_at", order: { created_at: :desc }
|
||||||
|
t.index ["event_id"], name: "index_tag_versions_on_event_id"
|
||||||
t.index ["tag_id", "created_at"], name: "index_tag_versions_on_tag_id_and_created_at", order: { created_at: :desc }
|
t.index ["tag_id", "created_at"], name: "index_tag_versions_on_tag_id_and_created_at", order: { created_at: :desc }
|
||||||
t.index ["tag_id", "version_no"], name: "index_tag_versions_on_tag_id_and_version_no", unique: true
|
t.index ["tag_id", "version_no"], name: "index_tag_versions_on_tag_id_and_version_no", unique: true
|
||||||
t.check_constraint "`version_no` > 0", name: "tag_versions_version_no_positive"
|
t.check_constraint "`version_no` > 0", name: "tag_versions_version_no_positive"
|
||||||
@@ -771,6 +796,7 @@ ActiveRecord::Schema[8.0].define(version: 2026_10_15_010000) do
|
|||||||
|
|
||||||
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
|
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
|
||||||
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
|
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
|
||||||
|
add_foreign_key "events", "events", column: "reverted_from_event_id"
|
||||||
add_foreign_key "gekanator_ai_runs", "gekanator_question_suggestions"
|
add_foreign_key "gekanator_ai_runs", "gekanator_question_suggestions"
|
||||||
add_foreign_key "gekanator_games", "posts", column: "correct_post_id"
|
add_foreign_key "gekanator_games", "posts", column: "correct_post_id"
|
||||||
add_foreign_key "gekanator_games", "posts", column: "guessed_post_id"
|
add_foreign_key "gekanator_games", "posts", column: "guessed_post_id"
|
||||||
@@ -792,6 +818,7 @@ ActiveRecord::Schema[8.0].define(version: 2026_10_15_010000) do
|
|||||||
add_foreign_key "material_sync_sources", "users", column: "created_by_user_id"
|
add_foreign_key "material_sync_sources", "users", column: "created_by_user_id"
|
||||||
add_foreign_key "material_sync_sources", "users", column: "updated_by_user_id"
|
add_foreign_key "material_sync_sources", "users", column: "updated_by_user_id"
|
||||||
add_foreign_key "material_sync_suppressions", "users", column: "created_by_user_id"
|
add_foreign_key "material_sync_suppressions", "users", column: "created_by_user_id"
|
||||||
|
add_foreign_key "material_versions", "events"
|
||||||
add_foreign_key "material_versions", "materials"
|
add_foreign_key "material_versions", "materials"
|
||||||
add_foreign_key "material_versions", "materials", column: "parent_id"
|
add_foreign_key "material_versions", "materials", column: "parent_id"
|
||||||
add_foreign_key "material_versions", "users", column: "created_by_user_id"
|
add_foreign_key "material_versions", "users", column: "created_by_user_id"
|
||||||
@@ -813,10 +840,12 @@ ActiveRecord::Schema[8.0].define(version: 2026_10_15_010000) do
|
|||||||
add_foreign_key "post_tags", "posts"
|
add_foreign_key "post_tags", "posts"
|
||||||
add_foreign_key "post_tags", "tags"
|
add_foreign_key "post_tags", "tags"
|
||||||
add_foreign_key "post_tags", "users", column: "created_user_id"
|
add_foreign_key "post_tags", "users", column: "created_user_id"
|
||||||
|
add_foreign_key "post_versions", "events"
|
||||||
add_foreign_key "post_versions", "posts"
|
add_foreign_key "post_versions", "posts"
|
||||||
add_foreign_key "post_versions", "users", column: "created_by_user_id"
|
add_foreign_key "post_versions", "users", column: "created_by_user_id"
|
||||||
add_foreign_key "posts", "users", column: "uploaded_user_id"
|
add_foreign_key "posts", "users", column: "uploaded_user_id"
|
||||||
add_foreign_key "settings", "users"
|
add_foreign_key "settings", "users"
|
||||||
|
add_foreign_key "tag_description_versions", "events"
|
||||||
add_foreign_key "tag_description_versions", "tag_description_versions", column: ["tag_id", "source_language_code", "source_version_no"], primary_key: ["tag_id", "language_code", "version_no"]
|
add_foreign_key "tag_description_versions", "tag_description_versions", column: ["tag_id", "source_language_code", "source_version_no"], primary_key: ["tag_id", "language_code", "version_no"]
|
||||||
add_foreign_key "tag_descriptions", "languages", column: "language_code", primary_key: "code"
|
add_foreign_key "tag_descriptions", "languages", column: "language_code", primary_key: "code"
|
||||||
add_foreign_key "tag_descriptions", "tag_description_versions", column: ["tag_id", "source_language_code", "source_version_no"], primary_key: ["tag_id", "language_code", "version_no"]
|
add_foreign_key "tag_descriptions", "tag_description_versions", column: ["tag_id", "source_language_code", "source_version_no"], primary_key: ["tag_id", "language_code", "version_no"]
|
||||||
@@ -835,6 +864,7 @@ ActiveRecord::Schema[8.0].define(version: 2026_10_15_010000) do
|
|||||||
add_foreign_key "tag_group_tags", "tag_groups"
|
add_foreign_key "tag_group_tags", "tag_groups"
|
||||||
add_foreign_key "tag_group_tags", "tags"
|
add_foreign_key "tag_group_tags", "tags"
|
||||||
add_foreign_key "tag_group_tags", "users", column: "created_by_user_id"
|
add_foreign_key "tag_group_tags", "users", column: "created_by_user_id"
|
||||||
|
add_foreign_key "tag_group_versions", "events"
|
||||||
add_foreign_key "tag_groups", "users", column: "created_by_user_id"
|
add_foreign_key "tag_groups", "users", column: "created_by_user_id"
|
||||||
add_foreign_key "tag_groups", "users", column: "updated_by_user_id"
|
add_foreign_key "tag_groups", "users", column: "updated_by_user_id"
|
||||||
add_foreign_key "tag_implications", "tags"
|
add_foreign_key "tag_implications", "tags"
|
||||||
@@ -844,6 +874,7 @@ ActiveRecord::Schema[8.0].define(version: 2026_10_15_010000) do
|
|||||||
add_foreign_key "tag_names", "tags"
|
add_foreign_key "tag_names", "tags"
|
||||||
add_foreign_key "tag_similarities", "tags"
|
add_foreign_key "tag_similarities", "tags"
|
||||||
add_foreign_key "tag_similarities", "tags", column: "target_tag_id"
|
add_foreign_key "tag_similarities", "tags", column: "target_tag_id"
|
||||||
|
add_foreign_key "tag_versions", "events"
|
||||||
add_foreign_key "tag_versions", "users", column: "created_by_user_id"
|
add_foreign_key "tag_versions", "users", column: "created_by_user_id"
|
||||||
add_foreign_key "theatre_comments", "theatres"
|
add_foreign_key "theatre_comments", "theatres"
|
||||||
add_foreign_key "theatre_comments", "users"
|
add_foreign_key "theatre_comments", "users"
|
||||||
|
|||||||
+2
-15
@@ -9,21 +9,8 @@
|
|||||||
# end
|
# end
|
||||||
|
|
||||||
ApplicationRecord.transaction do
|
ApplicationRecord.transaction do
|
||||||
Language.find_or_create_by!(code: 'ja') { _1.name = '日本語' }
|
Language.find_or_create_by!(code: 'ain') { _1.name = 'ain' }
|
||||||
Script.find_or_create_by!(code: 'Jpan') { _1.name = '漢字、仮名文字およびラテン文字' }
|
Script.find_or_create_by!(code: 'Kana') { _1.name = 'Kana' }
|
||||||
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
|
end
|
||||||
|
|
||||||
post_url_sanitisation_rules = [
|
post_url_sanitisation_rules = [
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ RSpec.describe 'Tag group persistence constraints' do
|
|||||||
6.times { nodes = [{ 'tag_id' => tag.id, 'children' => nodes }] }
|
6.times { nodes = [{ 'tag_id' => tag.id, 'children' => nodes }] }
|
||||||
reject_statement do
|
reject_statement do
|
||||||
insert_group_row('tag_group_versions', {
|
insert_group_row('tag_group_versions', {
|
||||||
|
event_id: create_event!(event_type: 'unknown').id,
|
||||||
tag_group_id: group_id, version_no: 2, event_type: 'update', name: '制約確認',
|
tag_group_id: group_id, version_no: 2, event_type: 'update', name: '制約確認',
|
||||||
members: [{ 'tag_id' => tag.id, 'version_no' => tag.version_no }].to_json,
|
members: [{ 'tag_id' => tag.id, 'version_no' => tag.version_no }].to_json,
|
||||||
placements: nodes.to_json, created_at: Time.current })
|
placements: nodes.to_json, created_at: Time.current })
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ RSpec.describe 'TagVersion names database constraint' do
|
|||||||
|
|
||||||
def insert_snapshot!(snapshot)
|
def insert_snapshot!(snapshot)
|
||||||
TagVersion.insert_all!([{ tag_id: tag.id, version_no: 1, event_type: 'create',
|
TagVersion.insert_all!([{ tag_id: tag.id, version_no: 1, event_type: 'create',
|
||||||
|
event_id: create_event!(event_type: 'unknown').id,
|
||||||
name: 'snapshot_name', category: 'general',
|
name: 'snapshot_name', category: 'general',
|
||||||
aliases: '', parent_tag_ids: '', names: snapshot,
|
aliases: '', parent_tag_ids: '', names: snapshot,
|
||||||
created_at: Time.current }])
|
created_at: Time.current }])
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ RSpec.describe HistoryAccess do
|
|||||||
locale
|
locale
|
||||||
rows = ['ja', 'ain'].flat_map do |language|
|
rows = ['ja', 'ain'].flat_map do |language|
|
||||||
['create', 'discard', 'restore'].each_with_index.map do |event, index|
|
['create', 'discard', 'restore'].each_with_index.map do |event, index|
|
||||||
TagDescriptionVersion.create!(tag_id: tag.id, language_code: language,
|
TagDescriptionVersion.create!(
|
||||||
|
event_id: create_event!(event_type: 'unknown').id,
|
||||||
|
tag_id: tag.id, language_code: language,
|
||||||
version_no: index + 1, event_type: language == 'ain' ? 'update' : event,
|
version_no: index + 1, event_type: language == 'ain' ? 'update' : event,
|
||||||
body: "#{ language } #{ index }", created_at: Time.utc(2024))
|
body: "#{ language } #{ index }", created_at: Time.utc(2024))
|
||||||
end
|
end
|
||||||
@@ -28,7 +30,9 @@ RSpec.describe HistoryAccess do
|
|||||||
public_metadata = described_class.set(user: nil) { described_class.authority }
|
public_metadata = described_class.set(user: nil) { described_class.authority }
|
||||||
admin = create(:user, :admin)
|
admin = create(:user, :admin)
|
||||||
admin_metadata = described_class.set(user: admin) { described_class.authority }
|
admin_metadata = described_class.set(user: admin) { described_class.authority }
|
||||||
TagVersion.create!(tag:, version_no: 1, event_type: 'discard', name: 'private',
|
TagVersion.create!(
|
||||||
|
event_id: create_event!(event_type: 'unknown').id,
|
||||||
|
tag:, version_no: 1, event_type: 'discard', name: 'private',
|
||||||
category: 'general',
|
category: 'general',
|
||||||
names: { ja: { name: 'private', script_code: 'Jpan',
|
names: { ja: { name: 'private', script_code: 'Jpan',
|
||||||
auto_generated: false, aliases: [] } },
|
auto_generated: false, aliases: [] } },
|
||||||
@@ -52,7 +56,9 @@ RSpec.describe HistoryAccess do
|
|||||||
blob = ActiveStorage::Blob.create_before_direct_upload!(filename: 'private.png',
|
blob = ActiveStorage::Blob.create_before_direct_upload!(filename: 'private.png',
|
||||||
byte_size: 1, checksum: Base64.strict_encode64('checksum'), content_type: 'image/png')
|
byte_size: 1, checksum: Base64.strict_encode64('checksum'), content_type: 'image/png')
|
||||||
ActiveStorage::Attachment.create!(name: 'thumbnail', record: post, blob:)
|
ActiveStorage::Attachment.create!(name: 'thumbnail', record: post, blob:)
|
||||||
PostVersion.create!(post:, version_no: 1, event_type: 'discard', url: post.url,
|
PostVersion.create!(
|
||||||
|
event_id: create_event!(event_type: 'unknown').id,
|
||||||
|
post:, version_no: 1, event_type: 'discard', url: post.url,
|
||||||
title: 'private', tags: '', tags_json: [], parent_post_ids: '', created_at: Time.utc(2024))
|
title: 'private', tags: '', tags_json: [], parent_post_ids: '', created_at: Time.utc(2024))
|
||||||
variant = ActiveStorage::VariantRecord.create!(blob:, variation_digest: 'spec-variation')
|
variant = ActiveStorage::VariantRecord.create!(blob:, variation_digest: 'spec-variation')
|
||||||
derived = ActiveStorage::Blob.create_before_direct_upload!(filename: 'derived.png',
|
derived = ActiveStorage::Blob.create_before_direct_upload!(filename: 'derived.png',
|
||||||
@@ -78,7 +84,9 @@ RSpec.describe 'Counts artifact policy and external references' do
|
|||||||
|
|
||||||
it 'does not expose a private linked tag name through an external history snapshot' do
|
it 'does not expose a private linked tag name through an external history snapshot' do
|
||||||
tag = create(:tag, primary_name: 'private-linked-name')
|
tag = create(:tag, primary_name: 'private-linked-name')
|
||||||
TagVersion.create!(tag:, version_no: 1, event_type: 'discard', name: 'private-linked-name',
|
TagVersion.create!(
|
||||||
|
event_id: create_event!(event_type: 'unknown').id,
|
||||||
|
tag:, version_no: 1, event_type: 'discard', name: 'private-linked-name',
|
||||||
category: 'general', aliases: '', parent_tag_ids: '', created_at: Time.utc(2024),
|
category: 'general', aliases: '', parent_tag_ids: '', created_at: Time.utc(2024),
|
||||||
names: { ja: { name: 'private-linked-name', script_code: 'Jpan',
|
names: { ja: { name: 'private-linked-name', script_code: 'Jpan',
|
||||||
auto_generated: false, aliases: [] } })
|
auto_generated: false, aliases: [] } })
|
||||||
@@ -87,7 +95,9 @@ RSpec.describe 'Counts artifact policy and external references' do
|
|||||||
name: 'nico:public-external', linked_tags: 'private-linked-name',
|
name: 'nico:public-external', linked_tags: 'private-linked-name',
|
||||||
created_at: Time.utc(2024))
|
created_at: Time.utc(2024))
|
||||||
post = create(:post)
|
post = create(:post)
|
||||||
snapshot = PostVersion.create!(post:, version_no: 1, event_type: 'create',
|
snapshot = PostVersion.create!(
|
||||||
|
event_id: create_event!(event_type: 'unknown').id,
|
||||||
|
post:, version_no: 1, event_type: 'create',
|
||||||
title: 'external reference', url: post.url, tags: '',
|
title: 'external reference', url: post.url, tags: '',
|
||||||
tags_json: [{ external_tag_id: external.id }], parent_post_ids: '',
|
tags_json: [{ external_tag_id: external.id }], parent_post_ids: '',
|
||||||
created_at: Time.utc(2024, 2))
|
created_at: Time.utc(2024, 2))
|
||||||
@@ -112,13 +122,17 @@ RSpec.describe 'Counts artifact policy and external references' do
|
|||||||
child = create(:tag, primary_name: 'referencing child')
|
child = create(:tag, primary_name: 'referencing child')
|
||||||
[[parent, 'discard', '', 'private parent'],
|
[[parent, 'discard', '', 'private parent'],
|
||||||
[child, 'create', parent.id.to_s, 'referencing child']].each do |tag, event, parents, name|
|
[child, 'create', parent.id.to_s, 'referencing child']].each do |tag, event, parents, name|
|
||||||
TagVersion.create!(tag:, version_no: 1, event_type: event, name:,
|
TagVersion.create!(
|
||||||
|
event_id: create_event!(event_type: 'unknown').id,
|
||||||
|
tag:, version_no: 1, event_type: event, name:,
|
||||||
category: 'general', aliases: '', parent_tag_ids: parents, created_at: Time.utc(2023),
|
category: 'general', aliases: '', parent_tag_ids: parents, created_at: Time.utc(2023),
|
||||||
names: { ja: { name:, script_code: 'Jpan',
|
names: { ja: { name:, script_code: 'Jpan',
|
||||||
auto_generated: false, aliases: [] } })
|
auto_generated: false, aliases: [] } })
|
||||||
end
|
end
|
||||||
post = create(:post)
|
post = create(:post)
|
||||||
row = PostVersion.create!(post:, version_no: 1, event_type: 'create', title: 'snapshot',
|
row = PostVersion.create!(
|
||||||
|
event_id: create_event!(event_type: 'unknown').id,
|
||||||
|
post:, version_no: 1, event_type: 'create', title: 'snapshot',
|
||||||
url: post.url, tags: '', parent_post_ids: '',
|
url: post.url, tags: '', parent_post_ids: '',
|
||||||
tags_json: [{ tag_id: child.id, version_no: 1, name: 'referencing child', sections: [] }],
|
tags_json: [{ tag_id: child.id, version_no: 1, name: 'referencing child', sections: [] }],
|
||||||
created_at: Time.utc(2024))
|
created_at: Time.utc(2024))
|
||||||
|
|||||||
@@ -23,9 +23,22 @@ RSpec.describe 'Locale activation', type: :model do
|
|||||||
.and_raise('backfill failed')
|
.and_raise('backfill failed')
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'groups all tag histories from locale creation into one event' do
|
||||||
|
previous_ids = TagVersion.pluck(:id)
|
||||||
|
|
||||||
|
expect { build_locale.save! }.to change { event_rows.length }.by(1)
|
||||||
|
|
||||||
|
event = event_rows.last
|
||||||
|
expect(event).to include('event_type' => 'locales.create', 'created_by_user_id' => nil)
|
||||||
|
versions = TagVersion.where.not(id: previous_ids)
|
||||||
|
expect(versions.pluck(:tag_id)).to include(first.id, second.id)
|
||||||
|
expect(versions.pluck(:event_id).uniq).to eq([event.fetch('id')])
|
||||||
|
end
|
||||||
|
|
||||||
it 'rolls back a newly created locale and the preceding generated names' do
|
it 'rolls back a newly created locale and the preceding generated names' do
|
||||||
locale = build_locale
|
locale = build_locale
|
||||||
fail_second_generation!
|
fail_second_generation!
|
||||||
|
events = event_rows
|
||||||
|
|
||||||
expect { locale.save! }.to raise_error('backfill failed')
|
expect { locale.save! }.to raise_error('backfill failed')
|
||||||
|
|
||||||
@@ -33,6 +46,7 @@ RSpec.describe 'Locale activation', type: :model do
|
|||||||
expect(generated_names).to be_empty
|
expect(generated_names).to be_empty
|
||||||
expect(first.reload.name('ja')).to eq('activation_first')
|
expect(first.reload.name('ja')).to eq('activation_first')
|
||||||
expect(second.reload.name('ja')).to eq('activation_second')
|
expect(second.reload.name('ja')).to eq('activation_second')
|
||||||
|
expect(event_rows).to eq(events)
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'does not backfill while the locale is inactive' do
|
it 'does not backfill while the locale is inactive' do
|
||||||
@@ -45,7 +59,14 @@ RSpec.describe 'Locale activation', type: :model do
|
|||||||
locale = build_locale(deprecated_at: Time.current)
|
locale = build_locale(deprecated_at: Time.current)
|
||||||
locale.save!
|
locale.save!
|
||||||
|
|
||||||
locale.update!(deprecated_at: nil)
|
previous_ids = TagVersion.pluck(:id)
|
||||||
|
expect { locale.update!(deprecated_at: nil) }.to change { event_rows.length }.by(1)
|
||||||
|
|
||||||
|
event = event_rows.last
|
||||||
|
expect(event).to include('event_type' => 'locales.restore', 'created_by_user_id' => nil)
|
||||||
|
versions = TagVersion.where.not(id: previous_ids)
|
||||||
|
expect(versions.pluck(:tag_id)).to include(first.id, second.id)
|
||||||
|
expect(versions.pluck(:event_id).uniq).to eq([event.fetch('id')])
|
||||||
|
|
||||||
expect(generated_names.pluck(:tag_id)).to contain_exactly(first.id, second.id)
|
expect(generated_names.pluck(:tag_id)).to contain_exactly(first.id, second.id)
|
||||||
expect(generated_names).to all(have_attributes(
|
expect(generated_names).to all(have_attributes(
|
||||||
@@ -57,11 +78,13 @@ RSpec.describe 'Locale activation', type: :model do
|
|||||||
locale.save!
|
locale.save!
|
||||||
previous_deprecated_at = locale.reload.deprecated_at
|
previous_deprecated_at = locale.reload.deprecated_at
|
||||||
fail_second_generation!
|
fail_second_generation!
|
||||||
|
events = event_rows
|
||||||
|
|
||||||
expect { locale.update!(deprecated_at: nil) }.to raise_error('backfill failed')
|
expect { locale.update!(deprecated_at: nil) }.to raise_error('backfill failed')
|
||||||
|
|
||||||
expect(locale.reload.deprecated_at).to eq(previous_deprecated_at)
|
expect(locale.reload.deprecated_at).to eq(previous_deprecated_at)
|
||||||
expect(generated_names).to be_empty
|
expect(generated_names).to be_empty
|
||||||
|
expect(event_rows).to eq(events)
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'preserves a human primary through deactivation and repeated activation' do
|
it 'preserves a human primary through deactivation and repeated activation' do
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ RSpec.describe PostVersion, type: :model do
|
|||||||
|
|
||||||
let!(:post_version) do
|
let!(:post_version) do
|
||||||
PostVersion.create!(
|
PostVersion.create!(
|
||||||
|
event_id: create_event!(event_type: 'unknown').id,
|
||||||
post: post_record,
|
post: post_record,
|
||||||
version_no: 1,
|
version_no: 1,
|
||||||
event_type: 'create',
|
event_type: 'create',
|
||||||
@@ -24,8 +25,7 @@ RSpec.describe PostVersion, type: :model do
|
|||||||
original_created_from: post_record.original_created_from,
|
original_created_from: post_record.original_created_from,
|
||||||
original_created_before: post_record.original_created_before,
|
original_created_before: post_record.original_created_before,
|
||||||
created_at: Time.current,
|
created_at: Time.current,
|
||||||
created_by_user: nil
|
created_by_user: nil)
|
||||||
)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'is read only after create' do
|
it 'is read only after create' do
|
||||||
|
|||||||
@@ -34,6 +34,31 @@ RSpec.describe TagNameSanitisationRule, type: :model do
|
|||||||
described_class.create!(priority: 10, source_pattern: 'foo_', replacement: 'foo')
|
described_class.create!(priority: 10, source_pattern: 'foo_', replacement: 'foo')
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'groups renames, merges and affected post histories into one event' do
|
||||||
|
target = create(:tag, primary_name: 'foobar')
|
||||||
|
source = create(:tag, primary_name: 'temporary_source')
|
||||||
|
renamed = create(:tag, primary_name: 'temporary_rename')
|
||||||
|
source.tag_name('ja').update_columns(name: 'foo_bar')
|
||||||
|
renamed.tag_name('ja').update_columns(name: 'foo_baz')
|
||||||
|
post = create(:post)
|
||||||
|
PostTag.create!(post:, tag: source)
|
||||||
|
previous_tag_ids = TagVersion.pluck(:id)
|
||||||
|
previous_post_ids = PostVersion.pluck(:id)
|
||||||
|
|
||||||
|
expect { described_class.apply! }.to change { event_rows.length }.by(1)
|
||||||
|
|
||||||
|
event = event_rows.last
|
||||||
|
expect(event).to include(
|
||||||
|
'event_type' => 'tag_name_sanitisation_rules.apply', 'created_by_user_id' => nil)
|
||||||
|
tag_versions = TagVersion.where.not(id: previous_tag_ids)
|
||||||
|
expect(tag_versions.pluck(:tag_id).uniq).to contain_exactly(target.id, source.id, renamed.id)
|
||||||
|
expect(tag_versions.pluck(:event_type)).to include('discard', 'update')
|
||||||
|
expect(tag_versions.pluck(:event_id).uniq).to eq([event.fetch('id')])
|
||||||
|
post_versions = PostVersion.where.not(id: previous_post_ids)
|
||||||
|
expect(post_versions.pluck(:post_id).uniq).to eq([post.id])
|
||||||
|
expect(post_versions.pluck(:event_id).uniq).to eq([event.fetch('id')])
|
||||||
|
end
|
||||||
|
|
||||||
[:primary, :alias].each do |source_kind|
|
[:primary, :alias].each do |source_kind|
|
||||||
it "keeps the primary identity when a #{ source_kind } collides within its Tag" do
|
it "keeps the primary identity when a #{ source_kind } collides within its Tag" do
|
||||||
tag = create(:tag, primary_name: source_kind == :primary ? 'temporary' : 'foobar')
|
tag = create(:tag, primary_name: source_kind == :primary ? 'temporary' : 'foobar')
|
||||||
@@ -83,6 +108,7 @@ RSpec.describe TagNameSanitisationRule, type: :model do
|
|||||||
PostTag.create!(post:, tag: source)
|
PostTag.create!(post:, tag: source)
|
||||||
models = [Tag, TagName, TagVersion, TagDescription, TagDescriptionVersion, PostTag]
|
models = [Tag, TagName, TagVersion, TagDescription, TagDescriptionVersion, PostTag]
|
||||||
snapshots = models.map { |model| model.order(*Array(model.primary_key)).map(&:attributes) }
|
snapshots = models.map { |model| model.order(*Array(model.primary_key)).map(&:attributes) }
|
||||||
|
events = event_rows
|
||||||
|
|
||||||
expect { described_class.apply! }.to raise_error(ActiveRecord::RecordInvalid)
|
expect { described_class.apply! }.to raise_error(ActiveRecord::RecordInvalid)
|
||||||
|
|
||||||
@@ -90,6 +116,7 @@ RSpec.describe TagNameSanitisationRule, type: :model do
|
|||||||
model.order(*Array(model.primary_key)).map(&:attributes)
|
model.order(*Array(model.primary_key)).map(&:attributes)
|
||||||
end
|
end
|
||||||
expect(after_state).to eq(snapshots)
|
expect(after_state).to eq(snapshots)
|
||||||
|
expect(event_rows).to eq(events)
|
||||||
expect(post.reload.tags).to contain_exactly(source)
|
expect(post.reload.tags).to contain_exactly(source)
|
||||||
expect(target.reload.name('ja')).to eq('foobar')
|
expect(target.reload.name('ja')).to eq('foobar')
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -665,6 +665,7 @@ RSpec.describe Tag, type: :model do
|
|||||||
|
|
||||||
def create_post_version_for!(post, version_no: 1, event_type: 'create', created_by_user: nil)
|
def create_post_version_for!(post, version_no: 1, event_type: 'create', created_by_user: nil)
|
||||||
PostVersion.create!(
|
PostVersion.create!(
|
||||||
|
event_id: create_event!(event_type: 'unknown').id,
|
||||||
post: post,
|
post: post,
|
||||||
version_no: version_no,
|
version_no: version_no,
|
||||||
event_type: event_type,
|
event_type: event_type,
|
||||||
@@ -677,8 +678,7 @@ RSpec.describe Tag, type: :model do
|
|||||||
original_created_from: post.original_created_from,
|
original_created_from: post.original_created_from,
|
||||||
original_created_before: post.original_created_before,
|
original_created_before: post.original_created_before,
|
||||||
created_at: Time.current,
|
created_at: Time.current,
|
||||||
created_by_user: created_by_user
|
created_by_user: created_by_user)
|
||||||
)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'when post versions are enabled' do
|
context 'when post versions are enabled' do
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ RSpec.describe VersionRecord, type: :model do
|
|||||||
|
|
||||||
it 'makes TagVersion read only after create' do
|
it 'makes TagVersion read only after create' do
|
||||||
version = TagVersion.create!(
|
version = TagVersion.create!(
|
||||||
|
event_id: create_event!(event_type: 'unknown').id,
|
||||||
tag: tag,
|
tag: tag,
|
||||||
version_no: 1,
|
version_no: 1,
|
||||||
event_type: 'create',
|
event_type: 'create',
|
||||||
@@ -25,6 +26,7 @@ RSpec.describe VersionRecord, type: :model do
|
|||||||
|
|
||||||
it 'prevents TagVersion destroy' do
|
it 'prevents TagVersion destroy' do
|
||||||
version = TagVersion.create!(
|
version = TagVersion.create!(
|
||||||
|
event_id: create_event!(event_type: 'unknown').id,
|
||||||
tag: tag,
|
tag: tag,
|
||||||
version_no: 1,
|
version_no: 1,
|
||||||
event_type: 'create',
|
event_type: 'create',
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ RSpec.configure do |config|
|
|||||||
end
|
end
|
||||||
|
|
||||||
config.include TestRecords
|
config.include TestRecords
|
||||||
|
config.include EventRecords
|
||||||
config.include RakeTaskHelper
|
config.include RakeTaskHelper
|
||||||
|
|
||||||
# FactoryBot の create / create_list を使へるやぅにする
|
# FactoryBot の create / create_list を使へるやぅにする
|
||||||
|
|||||||
@@ -84,7 +84,9 @@ RSpec.describe 'Counts native GET acknowledgement', type: :request do
|
|||||||
expect(response).to have_http_status(:not_found)
|
expect(response).to have_http_status(:not_found)
|
||||||
job, file, attempt = ready_job('json')
|
job, file, attempt = ready_job('json')
|
||||||
record = create(:post)
|
record = create(:post)
|
||||||
PostVersion.create!(post: record, version_no: 1, event_type: 'discard', title: 'private',
|
PostVersion.create!(
|
||||||
|
event_id: create_event!(event_type: 'unknown').id,
|
||||||
|
post: record, version_no: 1, event_type: 'discard', title: 'private',
|
||||||
url: record.url, tags: '', tags_json: [], parent_post_ids: '', created_at: Time.utc(2024))
|
url: record.url, tags: '', tags_json: [], parent_post_ids: '', created_at: Time.utc(2024))
|
||||||
get "/counts/export/files/#{ file }", params: { job:, attempt: }
|
get "/counts/export/files/#{ file }", params: { job:, attempt: }
|
||||||
expect(response).to have_http_status(:not_found)
|
expect(response).to have_http_status(:not_found)
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ RSpec.describe 'History boundaries shared with Counts', type: :request do
|
|||||||
|
|
||||||
before do
|
before do
|
||||||
['create', 'discard'].each_with_index do |event, index|
|
['create', 'discard'].each_with_index do |event, index|
|
||||||
TagDescriptionVersion.create!(tag_id: tag.id, language_code: 'ja',
|
TagDescriptionVersion.create!(
|
||||||
|
event_id: create_event!(event_type: 'unknown').id,
|
||||||
|
tag_id: tag.id, language_code: 'ja',
|
||||||
version_no: index + 1, event_type: event, body: 'private body',
|
version_no: index + 1, event_type: event, body: 'private body',
|
||||||
created_at: Time.utc(2024))
|
created_at: Time.utc(2024))
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,196 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -10,12 +10,16 @@ RSpec.describe 'Legacy Counts API', type: :request do
|
|||||||
category: 'general', sections: [] }
|
category: 'general', sections: [] }
|
||||||
[1, 2, 3].each do |number|
|
[1, 2, 3].each do |number|
|
||||||
time = Time.utc(2024, number == 3 ? 2 : 1, number, 0, 0)
|
time = Time.utc(2024, number == 3 ? 2 : 1, number, 0, 0)
|
||||||
PostVersion.create!(post:, version_no: number,
|
PostVersion.create!(
|
||||||
|
event_id: create_event!(event_type: 'unknown').id,
|
||||||
|
post:, version_no: number,
|
||||||
event_type: number == 1 ? 'create' : 'update',
|
event_type: number == 1 ? 'create' : 'update',
|
||||||
tags: '', url: post.url, title: "Snapshot #{ number }", parent_post_ids: '',
|
tags: '', url: post.url, title: "Snapshot #{ number }", parent_post_ids: '',
|
||||||
tags_json: number == 1 ? [snapshot] : [],
|
tags_json: number == 1 ? [snapshot] : [],
|
||||||
created_by_user: number == 3 ? nil : actor, created_at: time)
|
created_by_user: number == 3 ? nil : actor, created_at: time)
|
||||||
TagVersion.create!(tag:, version_no: number,
|
TagVersion.create!(
|
||||||
|
event_id: create_event!(event_type: 'unknown').id,
|
||||||
|
tag:, version_no: number,
|
||||||
event_type: number == 1 ? 'create' : 'update',
|
event_type: number == 1 ? 'create' : 'update',
|
||||||
name: 'counts tag', category: 'general', aliases: '', parent_tag_ids: '',
|
name: 'counts tag', category: 'general', aliases: '', parent_tag_ids: '',
|
||||||
names: { ja: { name: 'counts tag', script_code: 'Jpan',
|
names: { ja: { name: 'counts tag', script_code: 'Jpan',
|
||||||
|
|||||||
@@ -14,7 +14,9 @@ RSpec.describe 'Counts table API', type: :request do
|
|||||||
end
|
end
|
||||||
|
|
||||||
before do
|
before do
|
||||||
TagVersion.create!(tag:, version_no: 1, event_type: 'create', name: 'alpha',
|
TagVersion.create!(
|
||||||
|
event_id: create_event!(event_type: 'unknown').id,
|
||||||
|
tag:, version_no: 1, event_type: 'create', name: 'alpha',
|
||||||
category: 'general', aliases: '', parent_tag_ids: '',
|
category: 'general', aliases: '', parent_tag_ids: '',
|
||||||
names: { ja: { name: 'alpha', script_code: 'Jpan', auto_generated: false, aliases: [] } },
|
names: { ja: { name: 'alpha', script_code: 'Jpan', auto_generated: false, aliases: [] } },
|
||||||
created_at: Time.utc(2023))
|
created_at: Time.utc(2023))
|
||||||
@@ -24,7 +26,9 @@ RSpec.describe 'Counts table API', type: :request do
|
|||||||
{ tag_id: tag.id, version_no: 1, name: 'alpha', category: 'general', sections: [] },
|
{ tag_id: tag.id, version_no: 1, name: 'alpha', category: 'general', sections: [] },
|
||||||
{ external_tag_id: external.id }]
|
{ external_tag_id: external.id }]
|
||||||
[1, 2, 3].each do |number|
|
[1, 2, 3].each do |number|
|
||||||
PostVersion.create!(post: record, version_no: number,
|
PostVersion.create!(
|
||||||
|
event_id: create_event!(event_type: 'unknown').id,
|
||||||
|
post: record, version_no: number,
|
||||||
event_type: number == 1 ? 'create' : 'update', url: record.url,
|
event_type: number == 1 ? 'create' : 'update', url: record.url,
|
||||||
title: "snapshot #{ number }", tags: '', parent_post_ids: '',
|
title: "snapshot #{ number }", tags: '', parent_post_ids: '',
|
||||||
tags_json: snapshots.first(number == 1 ? 2 : (number == 2 ? 1 : 0)),
|
tags_json: snapshots.first(number == 1 ? 2 : (number == 2 ? 1 : 0)),
|
||||||
@@ -134,7 +138,9 @@ RSpec.describe 'Counts table API', type: :request do
|
|||||||
end
|
end
|
||||||
|
|
||||||
it 'checks the current latest flag beyond the execution timestamp' do
|
it 'checks the current latest flag beyond the execution timestamp' do
|
||||||
PostVersion.create!(post: record, version_no: 4, event_type: 'update',
|
PostVersion.create!(
|
||||||
|
event_id: create_event!(event_type: 'unknown').id,
|
||||||
|
post: record, version_no: 4, event_type: 'update',
|
||||||
url: record.url, title: 'after execution', tags: '', tags_json: [],
|
url: record.url, title: 'after execution', tags: '', tags_json: [],
|
||||||
parent_post_ids: '', created_at: Time.utc(2025, 2, 1))
|
parent_post_ids: '', created_at: Time.utc(2025, 2, 1))
|
||||||
result = query
|
result = query
|
||||||
@@ -188,12 +194,16 @@ RSpec.describe 'Counts table API', type: :request do
|
|||||||
tag.tag_names.find_by!(language_code: 'ja', primary_flg: true)
|
tag.tag_names.find_by!(language_code: 'ja', primary_flg: true)
|
||||||
.update!(name: 'renamed-alpha')
|
.update!(name: 'renamed-alpha')
|
||||||
create(:tag_name, :alias, tag:, name: 'alpha-alias')
|
create(:tag_name, :alias, tag:, name: 'alpha-alias')
|
||||||
TagVersion.create!(tag:, version_no: 2, event_type: 'update', name: 'renamed-alpha',
|
TagVersion.create!(
|
||||||
|
event_id: create_event!(event_type: 'unknown').id,
|
||||||
|
tag:, version_no: 2, event_type: 'update', name: 'renamed-alpha',
|
||||||
category: 'general', aliases: 'alpha-alias', parent_tag_ids: '',
|
category: 'general', aliases: 'alpha-alias', parent_tag_ids: '',
|
||||||
names: { ja: { name: 'renamed-alpha', script_code: 'Jpan', auto_generated: false,
|
names: { ja: { name: 'renamed-alpha', script_code: 'Jpan', auto_generated: false,
|
||||||
aliases: [{ name: 'alpha-alias', script_code: 'Jpan' }] } },
|
aliases: [{ name: 'alpha-alias', script_code: 'Jpan' }] } },
|
||||||
created_at: Time.utc(2024, 2, 1))
|
created_at: Time.utc(2024, 2, 1))
|
||||||
PostVersion.create!(post: record, version_no: 4, event_type: 'update',
|
PostVersion.create!(
|
||||||
|
event_id: create_event!(event_type: 'unknown').id,
|
||||||
|
post: record, version_no: 4, event_type: 'update',
|
||||||
url: record.url, title: 'after rename', tags: '', parent_post_ids: '',
|
url: record.url, title: 'after rename', tags: '', parent_post_ids: '',
|
||||||
tags_json: [{ tag_id: tag.id, version_no: 2, name: 'renamed-alpha',
|
tags_json: [{ tag_id: tag.id, version_no: 2, name: 'renamed-alpha',
|
||||||
category: 'general', sections: [] }],
|
category: 'general', sections: [] }],
|
||||||
@@ -257,7 +267,9 @@ RSpec.describe 'Counts table API', type: :request do
|
|||||||
|
|
||||||
it 'reports column overflow separately from malformed input' do
|
it 'reports column overflow separately from malformed input' do
|
||||||
101.times do |index|
|
101.times do |index|
|
||||||
PostVersion.create!(post: record, version_no: index + 4,
|
PostVersion.create!(
|
||||||
|
event_id: create_event!(event_type: 'unknown').id,
|
||||||
|
post: record, version_no: index + 4,
|
||||||
event_type: 'update', url: record.url, title: "column #{ index }",
|
event_type: 'update', url: record.url, title: "column #{ index }",
|
||||||
tags: '', tags_json: [], parent_post_ids: '', created_at: Time.utc(2024, 3))
|
tags: '', tags_json: [], parent_post_ids: '', created_at: Time.utc(2024, 3))
|
||||||
end
|
end
|
||||||
@@ -301,7 +313,9 @@ RSpec.describe 'Counts table API', type: :request do
|
|||||||
sign_in_as(create(:user, role)) if role
|
sign_in_as(create(:user, role)) if role
|
||||||
# Equal timestamps deliberately require version ordering, not a date comparison.
|
# Equal timestamps deliberately require version ordering, not a date comparison.
|
||||||
['discard', 'restore', 'discard', 'restore'].each_with_index do |event, index|
|
['discard', 'restore', 'discard', 'restore'].each_with_index do |event, index|
|
||||||
PostVersion.create!(post: record, version_no: index + 4, event_type: event,
|
PostVersion.create!(
|
||||||
|
event_id: create_event!(event_type: 'unknown').id,
|
||||||
|
post: record, version_no: index + 4, event_type: event,
|
||||||
url: record.url, title: event == 'restore' ? 'restored' : 'private snapshot',
|
url: record.url, title: event == 'restore' ? 'restored' : 'private snapshot',
|
||||||
tags: '', tags_json: [], parent_post_ids: '', created_at: Time.utc(2024, 3))
|
tags: '', tags_json: [], parent_post_ids: '', created_at: Time.utc(2024, 3))
|
||||||
end
|
end
|
||||||
@@ -329,7 +343,9 @@ RSpec.describe 'Counts table API', type: :request do
|
|||||||
end
|
end
|
||||||
|
|
||||||
it 'does not resolve private condition names even when the result set is empty' do
|
it 'does not resolve private condition names even when the result set is empty' do
|
||||||
TagVersion.create!(tag:, version_no: 2, event_type: 'discard', name: 'secret tag',
|
TagVersion.create!(
|
||||||
|
event_id: create_event!(event_type: 'unknown').id,
|
||||||
|
tag:, version_no: 2, event_type: 'discard', name: 'secret tag',
|
||||||
category: 'general', aliases: '', parent_tag_ids: '',
|
category: 'general', aliases: '', parent_tag_ids: '',
|
||||||
names: { ja: { name: 'secret tag', script_code: 'Jpan', auto_generated: false, aliases: [] } },
|
names: { ja: { name: 'secret tag', script_code: 'Jpan', auto_generated: false, aliases: [] } },
|
||||||
created_at: Time.utc(2024, 2))
|
created_at: Time.utc(2024, 2))
|
||||||
@@ -345,7 +361,9 @@ RSpec.describe 'Counts table API', type: :request do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
it 'includes private history in the public timeline without an exclusion count' do
|
it 'includes private history in the public timeline without an exclusion count' do
|
||||||
PostVersion.create!(post: record, version_no: 4, event_type: 'discard', url: record.url,
|
PostVersion.create!(
|
||||||
|
event_id: create_event!(event_type: 'unknown').id,
|
||||||
|
post: record, version_no: 4, event_type: 'discard', url: record.url,
|
||||||
title: 'hidden title', tags: '', tags_json: [], parent_post_ids: '',
|
title: 'hidden title', tags: '', tags_json: [], parent_post_ids: '',
|
||||||
created_at: Time.utc(2024, 3))
|
created_at: Time.utc(2024, 3))
|
||||||
result = query(mode: 'summary', rows: 'created_at:month', cols: 'event_type')
|
result = query(mode: 'summary', rows: 'created_at:month', cols: 'event_type')
|
||||||
@@ -354,25 +372,15 @@ RSpec.describe 'Counts table API', type: :request do
|
|||||||
expect(result['cells'].sum { _1['count'] }).to eq(4)
|
expect(result['cells'].sum { _1['count'] }).to eq(4)
|
||||||
expect(response.body).not_to include('hidden title')
|
expect(response.body).not_to include('hidden title')
|
||||||
masked = query(mode: 'summary', rows: 'resource_id', cols: '')
|
masked = query(mode: 'summary', rows: 'resource_id', cols: '')
|
||||||
expect(masked['cells'].map { _1['values'] }).to eq([[record.id]])
|
expect(masked['cells'].map { _1['values'] }).to eq([[CountsAxes::MASKED_VALUE]])
|
||||||
expect(masked['cells'].first['count']).to eq(4)
|
expect(masked['cells'].first['count']).to eq(4)
|
||||||
expect(query['versions']).to be_empty
|
expect(query['versions']).to be_empty
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'allows public identity predicates without exposing private snapshot fields' do
|
it 'rejects private summary predicates instead of exposing a count oracle' do
|
||||||
PostVersion.create!(post: record, version_no: 4, event_type: 'discard',
|
get '/counts/query', params: base.merge(mode: 'summary', resource_id: record.id.to_s)
|
||||||
url: record.url, title: 'hidden title', tags: '', tags_json: [],
|
expect(response).to have_http_status(:forbidden)
|
||||||
parent_post_ids: '', created_at: Time.utc(2024, 3))
|
expect(json).to eq('error' => 'private_condition')
|
||||||
|
|
||||||
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
|
end
|
||||||
|
|
||||||
it 'reports the combined axis limit without replacing it with a generic 400' do
|
it 'reports the combined axis limit without replacing it with a generic 400' do
|
||||||
|
|||||||
@@ -314,6 +314,7 @@ RSpec.describe 'Materials API', type: :request do
|
|||||||
.and change(Tag, :count).by(1)
|
.and change(Tag, :count).by(1)
|
||||||
.and change(TagName, :count).by(1)
|
.and change(TagName, :count).by(1)
|
||||||
.and change(MaterialVersion, :count).by(1)
|
.and change(MaterialVersion, :count).by(1)
|
||||||
|
.and change { event_rows.length }.by(1)
|
||||||
|
|
||||||
expect(response).to have_http_status(:created)
|
expect(response).to have_http_status(:created)
|
||||||
|
|
||||||
@@ -325,6 +326,12 @@ RSpec.describe 'Materials API', type: :request do
|
|||||||
expect(material.file.attached?).to be(true)
|
expect(material.file.attached?).to be(true)
|
||||||
expect(material.version_no).to eq(1)
|
expect(material.version_no).to eq(1)
|
||||||
expect(material.material_versions.first.event_type).to eq('create')
|
expect(material.material_versions.first.event_type).to eq('create')
|
||||||
|
event = event_rows.last
|
||||||
|
expect(event).to include(
|
||||||
|
'event_type' => 'materials.create',
|
||||||
|
'created_by_user_id' => member_user.id)
|
||||||
|
expect(material.material_versions.first.event_id).to eq(event.fetch('id'))
|
||||||
|
expect(material.tag.tag_versions.pluck(:event_id)).to eq([event.fetch('id')])
|
||||||
expect(material.material_export_items.first.export_path).to eq('伊地知ニジカ/created.png')
|
expect(material.material_export_items.first.export_path).to eq('伊地知ニジカ/created.png')
|
||||||
expect(material.material_versions.first.export_paths_json).to eq(
|
expect(material.material_versions.first.export_paths_json).to eq(
|
||||||
'legacy_drive' => '伊地知ニジカ/created.png'
|
'legacy_drive' => '伊地知ニジカ/created.png'
|
||||||
@@ -509,6 +516,7 @@ RSpec.describe 'Materials API', type: :request do
|
|||||||
export_paths: { legacy_drive: '伊地知ニジカ/updated.jpg' }
|
export_paths: { legacy_drive: '伊地知ニジカ/updated.jpg' }
|
||||||
}
|
}
|
||||||
end.to change(MaterialVersion, :count).by(2)
|
end.to change(MaterialVersion, :count).by(2)
|
||||||
|
.and change { event_rows.length }.by(1)
|
||||||
|
|
||||||
expect(response).to have_http_status(:ok)
|
expect(response).to have_http_status(:ok)
|
||||||
|
|
||||||
@@ -517,6 +525,12 @@ RSpec.describe 'Materials API', type: :request do
|
|||||||
expect(material.tag.category).to eq('material')
|
expect(material.tag.category).to eq('material')
|
||||||
expect(material.url).to eq('https://example.com/updated-source')
|
expect(material.url).to eq('https://example.com/updated-source')
|
||||||
expect(material.updated_by_user).to eq(member_user)
|
expect(material.updated_by_user).to eq(member_user)
|
||||||
|
event = event_rows.last
|
||||||
|
expect(event).to include(
|
||||||
|
'event_type' => 'materials.update',
|
||||||
|
'created_by_user_id' => member_user.id)
|
||||||
|
expect(material.material_versions.pluck(:event_id).uniq).to eq([event.fetch('id')])
|
||||||
|
expect(material.tag.tag_versions.pluck(:event_id)).to eq([event.fetch('id')])
|
||||||
expect(material.file.attached?).to be(true)
|
expect(material.file.attached?).to be(true)
|
||||||
expect(material.file.blob.id).not_to eq(old_blob_id)
|
expect(material.file.blob.id).not_to eq(old_blob_id)
|
||||||
expect(ActiveStorage::Blob.where(id: old_blob_id).exists?).to be(true)
|
expect(ActiveStorage::Blob.where(id: old_blob_id).exists?).to be(true)
|
||||||
@@ -632,6 +646,7 @@ RSpec.describe 'Materials API', type: :request do
|
|||||||
MaterialVersionRecorder.record!(material:, event_type: :create,
|
MaterialVersionRecorder.record!(material:, event_type: :create,
|
||||||
created_by_user: member_user)
|
created_by_user: member_user)
|
||||||
TagVersioning.ensure_snapshot!(tag, created_by_user: member_user)
|
TagVersioning.ensure_snapshot!(tag, created_by_user: member_user)
|
||||||
|
events = event_rows
|
||||||
|
|
||||||
expect do
|
expect do
|
||||||
put "/materials/#{ material.id }", params: {
|
put "/materials/#{ material.id }", params: {
|
||||||
@@ -643,6 +658,7 @@ RSpec.describe 'Materials API', type: :request do
|
|||||||
|
|
||||||
expect(response).to have_http_status(:ok)
|
expect(response).to have_http_status(:ok)
|
||||||
expect(material.reload.version_no).to eq(1)
|
expect(material.reload.version_no).to eq(1)
|
||||||
|
expect(event_rows).to eq(events)
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'records update version when only export_path changes' do
|
it 'records update version when only export_path changes' do
|
||||||
@@ -807,6 +823,12 @@ RSpec.describe 'Materials API', type: :request do
|
|||||||
|
|
||||||
it 'creates source suppression as member' do
|
it 'creates source suppression as member' do
|
||||||
sign_in_as(member_user)
|
sign_in_as(member_user)
|
||||||
|
materials = ['a', 'b'].map do |name|
|
||||||
|
Material.create!(
|
||||||
|
source_kind: 'google_drive_file', source_file_id: "blocked-#{ name }",
|
||||||
|
source_path: "伊地知ニジカ/危険素材/#{ name }.png",
|
||||||
|
url: "https://example.com/blocked-#{ name }", created_by_user: member_user)
|
||||||
|
end
|
||||||
|
|
||||||
expect do
|
expect do
|
||||||
post '/materials/suppressions', params: {
|
post '/materials/suppressions', params: {
|
||||||
@@ -815,6 +837,7 @@ RSpec.describe 'Materials API', type: :request do
|
|||||||
reason: 'copyright_high_risk'
|
reason: 'copyright_high_risk'
|
||||||
}
|
}
|
||||||
end.to change(MaterialSyncSuppression, :count).by(1)
|
end.to change(MaterialSyncSuppression, :count).by(1)
|
||||||
|
.and change { event_rows.length }.by(1)
|
||||||
|
|
||||||
expect(response).to have_http_status(:created)
|
expect(response).to have_http_status(:created)
|
||||||
expect(json).to include(
|
expect(json).to include(
|
||||||
@@ -822,6 +845,14 @@ RSpec.describe 'Materials API', type: :request do
|
|||||||
'drive_path' => '伊地知ニジカ/危険素材',
|
'drive_path' => '伊地知ニジカ/危険素材',
|
||||||
'normalized_source_key' => 'google_drive_path_prefix:伊地知ニジカ/危険素材'
|
'normalized_source_key' => 'google_drive_path_prefix:伊地知ニジカ/危険素材'
|
||||||
)
|
)
|
||||||
|
event = event_rows.last
|
||||||
|
expect(event).to include(
|
||||||
|
'event_type' => 'materials.block', 'created_by_user_id' => member_user.id)
|
||||||
|
expect(materials.map { _1.reload.discarded? }).to eq([true, true])
|
||||||
|
versions = MaterialVersion.where(material_id: materials.map(&:id))
|
||||||
|
expect(versions.where(event_type: 'discard').pluck(:material_id))
|
||||||
|
.to match_array(materials.map(&:id))
|
||||||
|
expect(versions.pluck(:event_id).uniq).to eq([event.fetch('id')])
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -860,11 +891,19 @@ RSpec.describe 'Materials API', type: :request do
|
|||||||
end
|
end
|
||||||
|
|
||||||
it 'discards the material and returns 204' do
|
it 'discards the material and returns 204' do
|
||||||
delete "/materials/#{ material.id }"
|
expect { delete "/materials/#{ material.id }" }
|
||||||
|
.to change { event_rows.length }.by(1)
|
||||||
|
|
||||||
expect(response).to have_http_status(:no_content)
|
expect(response).to have_http_status(:no_content)
|
||||||
expect(Material.find_by(id: material.id)).to be_nil
|
expect(Material.find_by(id: material.id)).to be_nil
|
||||||
expect(Material.with_discarded.find(material.id)).to be_discarded
|
expect(Material.with_discarded.find(material.id)).to be_discarded
|
||||||
|
event = event_rows.last
|
||||||
|
expect(event).to include(
|
||||||
|
'event_type' => 'materials.destroy',
|
||||||
|
'created_by_user_id' => member_user.id)
|
||||||
|
versions = MaterialVersion.where(material_id: material.id)
|
||||||
|
expect(versions.order(:version_no).last.event_type).to eq('discard')
|
||||||
|
expect(versions.pluck(:event_id).uniq).to eq([event.fetch('id')])
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -177,6 +177,7 @@ RSpec.describe 'NicoTags', type: :request do
|
|||||||
}
|
}
|
||||||
}.to change(TagVersion, :count).by(2)
|
}.to change(TagVersion, :count).by(2)
|
||||||
.and change(NicoTagVersion, :count).by(1)
|
.and change(NicoTagVersion, :count).by(1)
|
||||||
|
.and change { event_rows.length }.by(1)
|
||||||
|
|
||||||
expect(response).to have_http_status(:ok)
|
expect(response).to have_http_status(:ok)
|
||||||
|
|
||||||
@@ -186,6 +187,10 @@ RSpec.describe 'NicoTags', type: :request do
|
|||||||
linked_versions = TagVersion.where(tag: [linked_a, linked_b]).order(:tag_id)
|
linked_versions = TagVersion.where(tag: [linked_a, linked_b]).order(:tag_id)
|
||||||
expect(linked_versions.map(&:event_type)).to eq(['create', 'create'])
|
expect(linked_versions.map(&:event_type)).to eq(['create', 'create'])
|
||||||
expect(linked_versions.map(&:created_by_user_id)).to all(eq(admin.id))
|
expect(linked_versions.map(&:created_by_user_id)).to all(eq(admin.id))
|
||||||
|
event = event_rows.last
|
||||||
|
expect(event).to include(
|
||||||
|
'event_type' => 'tags.update', 'created_by_user_id' => admin.id)
|
||||||
|
expect(linked_versions.pluck(:event_id).uniq).to eq([event.fetch('id')])
|
||||||
|
|
||||||
versions = nico_tag.reload.nico_tag_versions.order(:version_no)
|
versions = nico_tag.reload.nico_tag_versions.order(:version_no)
|
||||||
expect(versions.map(&:event_type)).to eq(['create', 'update'])
|
expect(versions.map(&:event_type)).to eq(['create', 'update'])
|
||||||
|
|||||||
@@ -1,129 +0,0 @@
|
|||||||
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',
|
|
||||||
name: 'nico:external-history-tag', linked_tags: 'private-history-tag',
|
|
||||||
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
|
|
||||||
@@ -50,6 +50,8 @@ RSpec.describe 'Posts API', type: :request do
|
|||||||
post:,
|
post:,
|
||||||
version_no: 1,
|
version_no: 1,
|
||||||
event_type: 'create',
|
event_type: 'create',
|
||||||
|
event_id: create_event!(
|
||||||
|
event_type: 'posts.create', created_by_user: post.uploaded_user).id,
|
||||||
title: post.title,
|
title: post.title,
|
||||||
url: post.url,
|
url: post.url,
|
||||||
thumbnail_base: post.thumbnail_base,
|
thumbnail_base: post.thumbnail_base,
|
||||||
@@ -629,37 +631,6 @@ RSpec.describe 'Posts API', type: :request do
|
|||||||
create(:post_tag, post: plain_post, tag:, created_at: t3)
|
create(:post_tag, post: plain_post, tag:, created_at: t3)
|
||||||
end
|
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|
|
['asc', 'desc'].each do |direction|
|
||||||
it "sorts by the later of post update and latest version time (#{ direction })" do
|
it "sorts by the later of post update and latest version time (#{ direction })" do
|
||||||
get '/posts', params: { url: '/version-time/', order: "updated_at:#{ direction }" }
|
get '/posts', params: { url: '/version-time/', order: "updated_at:#{ direction }" }
|
||||||
@@ -689,16 +660,6 @@ RSpec.describe 'Posts API', type: :request do
|
|||||||
expect(json.fetch('posts').map { |item| item.fetch('id') }).to eq([history_post.id])
|
expect(json.fetch('posts').map { |item| item.fetch('id') }).to eq([history_post.id])
|
||||||
expect(json.fetch('count')).to eq(1)
|
expect(json.fetch('count')).to eq(1)
|
||||||
end
|
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
|
end
|
||||||
|
|
||||||
context 'when original_created_from/original_created_to are provided' do
|
context 'when original_created_from/original_created_to are provided' do
|
||||||
@@ -1068,7 +1029,7 @@ RSpec.describe 'Posts API', type: :request do
|
|||||||
|
|
||||||
it 'dry-runs without persisting posts or new tags' do
|
it 'dry-runs without persisting posts or new tags' do
|
||||||
sign_in_as(member)
|
sign_in_as(member)
|
||||||
counts = [Post.count, Tag.count, TagName.count]
|
counts = [Post.count, Tag.count, TagName.count, event_rows.length]
|
||||||
|
|
||||||
post '/posts?dry=1', params: post_write_params(
|
post '/posts?dry=1', params: post_write_params(
|
||||||
title: 'dry-run post',
|
title: 'dry-run post',
|
||||||
@@ -1084,7 +1045,7 @@ RSpec.describe 'Posts API', type: :request do
|
|||||||
[{ 'name' => 'new_dry_run_tag',
|
[{ 'name' => 'new_dry_run_tag',
|
||||||
'category' => 'character',
|
'category' => 'character',
|
||||||
'section_literals' => [] }])
|
'section_literals' => [] }])
|
||||||
expect([Post.count, Tag.count, TagName.count]).to eq(counts)
|
expect([Post.count, Tag.count, TagName.count, event_rows.length]).to eq(counts)
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'dry-runs an existing URL without validating its upload' do
|
it 'dry-runs an existing URL without validating its upload' do
|
||||||
@@ -2259,6 +2220,7 @@ RSpec.describe 'Posts API', type: :request do
|
|||||||
|
|
||||||
def create_post_version! post, version_no:, event_type:, created_by_user:, created_at:
|
def create_post_version! post, version_no:, event_type:, created_by_user:, created_at:
|
||||||
PostVersion.create!(
|
PostVersion.create!(
|
||||||
|
event_id: create_event!(event_type: 'unknown').id,
|
||||||
post:,
|
post:,
|
||||||
version_no:,
|
version_no:,
|
||||||
event_type:,
|
event_type:,
|
||||||
@@ -2271,8 +2233,7 @@ RSpec.describe 'Posts API', type: :request do
|
|||||||
original_created_from: post.original_created_from,
|
original_created_from: post.original_created_from,
|
||||||
original_created_before: post.original_created_before,
|
original_created_before: post.original_created_before,
|
||||||
created_at:,
|
created_at:,
|
||||||
created_by_user:
|
created_by_user:)
|
||||||
)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
let!(:v1) do
|
let!(:v1) do
|
||||||
@@ -2600,6 +2561,7 @@ RSpec.describe 'Posts API', type: :request do
|
|||||||
tags: 'spec_tag',
|
tags: 'spec_tag',
|
||||||
thumbnail: dummy_upload)
|
thumbnail: dummy_upload)
|
||||||
end.to change(PostVersion, :count).by(1)
|
end.to change(PostVersion, :count).by(1)
|
||||||
|
.and change { event_rows.length }.by(1)
|
||||||
|
|
||||||
expect(response).to have_http_status(:created)
|
expect(response).to have_http_status(:created)
|
||||||
|
|
||||||
@@ -2611,6 +2573,13 @@ RSpec.describe 'Posts API', type: :request do
|
|||||||
expect(version.url).to eq('https://example.com/versioned-post')
|
expect(version.url).to eq('https://example.com/versioned-post')
|
||||||
expect(version.created_by_user_id).to eq(member.id)
|
expect(version.created_by_user_id).to eq(member.id)
|
||||||
expect(version.tags).to eq(snapshot_tags(created_post))
|
expect(version.tags).to eq(snapshot_tags(created_post))
|
||||||
|
event = event_rows.last
|
||||||
|
expect(event).to include(
|
||||||
|
'event_type' => 'posts.create', 'created_by_user_id' => member.id)
|
||||||
|
expect(version.event_id).to eq(event.fetch('id'))
|
||||||
|
tag_versions = TagVersion.where(tag_id: created_post.tags.select(:id))
|
||||||
|
expect(tag_versions).not_to be_empty
|
||||||
|
expect(tag_versions.pluck(:event_id).uniq).to eq([event.fetch('id')])
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'creates next version on PUT /posts/:id when snapshot changes' do
|
it 'creates next version on PUT /posts/:id when snapshot changes' do
|
||||||
@@ -2626,6 +2595,7 @@ RSpec.describe 'Posts API', type: :request do
|
|||||||
title: 'updated title',
|
title: 'updated title',
|
||||||
tags: 'spec_tag_2')
|
tags: 'spec_tag_2')
|
||||||
end.to change(PostVersion, :count).by(1)
|
end.to change(PostVersion, :count).by(1)
|
||||||
|
.and change { event_rows.length }.by(1)
|
||||||
|
|
||||||
expect(response).to have_http_status(:ok)
|
expect(response).to have_http_status(:ok)
|
||||||
|
|
||||||
@@ -2635,6 +2605,12 @@ RSpec.describe 'Posts API', type: :request do
|
|||||||
expect(version.title).to eq('updated title')
|
expect(version.title).to eq('updated title')
|
||||||
expect(version.created_by_user_id).to eq(member.id)
|
expect(version.created_by_user_id).to eq(member.id)
|
||||||
expect(version.tags).to eq(snapshot_tags(post_record.reload))
|
expect(version.tags).to eq(snapshot_tags(post_record.reload))
|
||||||
|
event = event_rows.last
|
||||||
|
expect(event).to include(
|
||||||
|
'event_type' => 'posts.update', 'created_by_user_id' => member.id)
|
||||||
|
expect(version.event_id).to eq(event.fetch('id'))
|
||||||
|
tag_version = TagVersion.find_by!(tag_id: tag_name2.reload.tag_id, version_no: 1)
|
||||||
|
expect(tag_version.event_id).to eq(event.fetch('id'))
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'does not create a new version on PUT /posts/:id when snapshot is unchanged' do
|
it 'does not create a new version on PUT /posts/:id when snapshot is unchanged' do
|
||||||
@@ -2643,6 +2619,7 @@ RSpec.describe 'Posts API', type: :request do
|
|||||||
PostTag.create!(post: post_record, tag: Tag.no_deerjikist)
|
PostTag.create!(post: post_record, tag: Tag.no_deerjikist)
|
||||||
base_version = create_post_version_for!(post_record.reload)
|
base_version = create_post_version_for!(post_record.reload)
|
||||||
|
|
||||||
|
events = event_rows
|
||||||
expect {
|
expect {
|
||||||
put "/posts/#{post_record.id}", params: post_write_params(
|
put "/posts/#{post_record.id}", params: post_write_params(
|
||||||
base_version_no: base_version.version_no,
|
base_version_no: base_version.version_no,
|
||||||
@@ -2656,10 +2633,12 @@ RSpec.describe 'Posts API', type: :request do
|
|||||||
expect(version.version_no).to eq(1)
|
expect(version.version_no).to eq(1)
|
||||||
expect(version.event_type).to eq('create')
|
expect(version.event_type).to eq('create')
|
||||||
expect(version.tags).to eq(snapshot_tags(post_record))
|
expect(version.tags).to eq(snapshot_tags(post_record))
|
||||||
|
expect(event_rows).to eq(events)
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'does not create a version when POST /posts is invalid' do
|
it 'does not create a version when POST /posts is invalid' do
|
||||||
sign_in_as(member)
|
sign_in_as(member)
|
||||||
|
events = event_rows
|
||||||
|
|
||||||
expect do
|
expect do
|
||||||
post '/posts', params: post_write_params(
|
post '/posts', params: post_write_params(
|
||||||
@@ -2670,11 +2649,13 @@ RSpec.describe 'Posts API', type: :request do
|
|||||||
end.not_to change(PostVersion, :count)
|
end.not_to change(PostVersion, :count)
|
||||||
|
|
||||||
expect(response).to have_http_status(:unprocessable_entity)
|
expect(response).to have_http_status(:unprocessable_entity)
|
||||||
|
expect(event_rows).to eq(events)
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'does not create a version when PUT /posts/:id is invalid' do
|
it 'does not create a version when PUT /posts/:id is invalid' do
|
||||||
sign_in_as(member)
|
sign_in_as(member)
|
||||||
base_version = create_post_version_for!(post_record)
|
base_version = create_post_version_for!(post_record)
|
||||||
|
events = event_rows
|
||||||
|
|
||||||
expect do
|
expect do
|
||||||
put "/posts/#{post_record.id}", params: post_write_params(
|
put "/posts/#{post_record.id}", params: post_write_params(
|
||||||
@@ -2686,6 +2667,7 @@ RSpec.describe 'Posts API', type: :request do
|
|||||||
end.not_to change(PostVersion, :count)
|
end.not_to change(PostVersion, :count)
|
||||||
|
|
||||||
expect(response).to have_http_status(:unprocessable_entity)
|
expect(response).to have_http_status(:unprocessable_entity)
|
||||||
|
expect(event_rows).to eq(events)
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'rejects second-bearing original created timestamps on POST /posts' do
|
it 'rejects second-bearing original created timestamps on POST /posts' do
|
||||||
|
|||||||
@@ -119,7 +119,9 @@ RSpec.describe 'Sitemap entries API', type: :request do
|
|||||||
# Legacy empty bodies can exist even though the write service rejects them.
|
# Legacy empty bodies can exist even though the write service rejects them.
|
||||||
empty = TagDescription.create!(tag: empty_tag, language_code: 'ain',
|
empty = TagDescription.create!(tag: empty_tag, language_code: 'ain',
|
||||||
body: '', version_no: 1)
|
body: '', version_no: 1)
|
||||||
TagDescriptionVersion.create!(tag_id: empty_tag.id, language_code: 'ain',
|
TagDescriptionVersion.create!(
|
||||||
|
event_id: create_event!(event_type: 'unknown').id,
|
||||||
|
tag_id: empty_tag.id, language_code: 'ain',
|
||||||
body: '', version_no: 1, event_type: 'create',
|
body: '', version_no: 1, event_type: 'create',
|
||||||
created_by_user_id: user.id, created_at: Time.current)
|
created_by_user_id: user.id, created_at: Time.current)
|
||||||
|
|
||||||
|
|||||||
@@ -53,8 +53,13 @@ RSpec.describe "TagChildren", type: :request do
|
|||||||
expect { do_request }
|
expect { do_request }
|
||||||
.to change { parent.reload.children.ids.include?(child.id) }
|
.to change { parent.reload.children.ids.include?(child.id) }
|
||||||
.from(false).to(true)
|
.from(false).to(true)
|
||||||
|
.and change { event_rows.length }.by(1)
|
||||||
|
|
||||||
expect(response).to have_http_status(:no_content)
|
expect(response).to have_http_status(:no_content)
|
||||||
|
event = event_rows.last
|
||||||
|
expect(event).to include(
|
||||||
|
'event_type' => 'tags.update', 'created_by_user_id' => admin.id)
|
||||||
|
expect(child.tag_versions.pluck(:event_id).uniq).to eq([event.fetch('id')])
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'returns 422 and does not create relation when the new link makes a cycle' do
|
it 'returns 422 and does not create relation when the new link makes a cycle' do
|
||||||
@@ -132,6 +137,7 @@ RSpec.describe "TagChildren", type: :request do
|
|||||||
expect {
|
expect {
|
||||||
do_request
|
do_request
|
||||||
}.to change(TagVersion, :count).by(2)
|
}.to change(TagVersion, :count).by(2)
|
||||||
|
.and change { event_rows.length }.by(1)
|
||||||
|
|
||||||
expect(response).to have_http_status(:no_content)
|
expect(response).to have_http_status(:no_content)
|
||||||
|
|
||||||
@@ -140,6 +146,10 @@ RSpec.describe "TagChildren", type: :request do
|
|||||||
expect(versions.first.parent_tag_ids.split).to include(parent.id.to_s)
|
expect(versions.first.parent_tag_ids.split).to include(parent.id.to_s)
|
||||||
expect(versions.second.parent_tag_ids).to eq('')
|
expect(versions.second.parent_tag_ids).to eq('')
|
||||||
expect(versions.second.created_by_user_id).to eq(admin.id)
|
expect(versions.second.created_by_user_id).to eq(admin.id)
|
||||||
|
event = event_rows.last
|
||||||
|
expect(event).to include(
|
||||||
|
'event_type' => 'tags.update', 'created_by_user_id' => admin.id)
|
||||||
|
expect(versions.pluck(:event_id).uniq).to eq([event.fetch('id')])
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ RSpec.describe 'Tag description Wiki compatibility', type: :request do
|
|||||||
let!(:versions) do
|
let!(:versions) do
|
||||||
['A', 'A', 'new-version'].each_with_index.map do |body, index|
|
['A', 'A', 'new-version'].each_with_index.map do |body, index|
|
||||||
TagDescriptionVersion.create!(
|
TagDescriptionVersion.create!(
|
||||||
|
event_id: create_event!(event_type: 'unknown').id,
|
||||||
tag_id: tag.id, language_code: 'ja', version_no: index + 1,
|
tag_id: tag.id, language_code: 'ja', version_no: index + 1,
|
||||||
event_type: index.zero? ? 'create' : 'update', body:,
|
event_type: index.zero? ? 'create' : 'update', body:,
|
||||||
created_by_user_id: user.id, created_at: Time.current)
|
created_by_user_id: user.id, created_at: Time.current)
|
||||||
@@ -73,6 +74,7 @@ RSpec.describe 'Tag description Wiki compatibility', type: :request do
|
|||||||
let!(:other_tag) { create(:tag, primary_name: 'other_description_contract') }
|
let!(:other_tag) { create(:tag, primary_name: 'other_description_contract') }
|
||||||
let!(:other_version) do
|
let!(:other_version) do
|
||||||
TagDescriptionVersion.create!(
|
TagDescriptionVersion.create!(
|
||||||
|
event_id: create_event!(event_type: 'unknown').id,
|
||||||
tag_id: other_tag.id, language_code: 'ja', version_no: 1,
|
tag_id: other_tag.id, language_code: 'ja', version_no: 1,
|
||||||
event_type: 'create', body: 'other tag history',
|
event_type: 'create', body: 'other tag history',
|
||||||
created_by_user_id: user.id, created_at: Time.current)
|
created_by_user_id: user.id, created_at: Time.current)
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ RSpec.describe 'Tag description native API', type: :request do
|
|||||||
def stored_state
|
def stored_state
|
||||||
[TagDescription, TagDescriptionVersion, TagName, TagVersion].map do |model|
|
[TagDescription, TagDescriptionVersion, TagName, TagVersion].map do |model|
|
||||||
model.order(:id).map(&:attributes)
|
model.order(:id).map(&:attributes)
|
||||||
end
|
end + [event_rows]
|
||||||
end
|
end
|
||||||
|
|
||||||
def fail_save model
|
def fail_save model
|
||||||
@@ -91,6 +91,8 @@ RSpec.describe 'Tag description native API', type: :request do
|
|||||||
let!(:version) do
|
let!(:version) do
|
||||||
TagDescriptionVersion.create!(
|
TagDescriptionVersion.create!(
|
||||||
tag_id: tag.id, language_code: 'ja', body: 'native version', version_no: 1,
|
tag_id: tag.id, language_code: 'ja', body: 'native version', version_no: 1,
|
||||||
|
event_id: create_event!(
|
||||||
|
event_type: 'tag_descriptions.create', created_by_user: author).id,
|
||||||
event_type: 'create', created_by_user_id: author.id, created_at: Time.current)
|
event_type: 'create', created_by_user_id: author.id, created_at: Time.current)
|
||||||
end
|
end
|
||||||
let(:name_endpoint) { "/tags/name/#{ tag_name.name }/description" }
|
let(:name_endpoint) { "/tags/name/#{ tag_name.name }/description" }
|
||||||
@@ -185,6 +187,7 @@ RSpec.describe 'Tag description native API', type: :request do
|
|||||||
expect { native_write(body: 'created natively') }
|
expect { native_write(body: 'created natively') }
|
||||||
.to change(TagDescription, :count).by(1)
|
.to change(TagDescription, :count).by(1)
|
||||||
.and change(TagDescriptionVersion, :count).by(1)
|
.and change(TagDescriptionVersion, :count).by(1)
|
||||||
|
.and change { event_rows.length }.by(1)
|
||||||
expect(response).to have_http_status(:created)
|
expect(response).to have_http_status(:created)
|
||||||
description = TagDescription.find_by!(tag_id: tag.id, language_code: 'ja')
|
description = TagDescription.find_by!(tag_id: tag.id, language_code: 'ja')
|
||||||
expect_current(description)
|
expect_current(description)
|
||||||
@@ -192,6 +195,10 @@ RSpec.describe 'Tag description native API', type: :request do
|
|||||||
expect(version).to have_attributes(
|
expect(version).to have_attributes(
|
||||||
body: 'created natively', version_no: 1, event_type: 'create',
|
body: 'created natively', version_no: 1, event_type: 'create',
|
||||||
created_by_user_id: editor.id)
|
created_by_user_id: editor.id)
|
||||||
|
event = event_rows.last
|
||||||
|
expect(event).to include(
|
||||||
|
'event_type' => 'tag_descriptions.create', 'created_by_user_id' => editor.id)
|
||||||
|
expect(version.event_id).to eq(event.fetch('id'))
|
||||||
get "/wiki/#{ description.id }"
|
get "/wiki/#{ description.id }"
|
||||||
expect(response).to have_http_status(:ok)
|
expect(response).to have_http_status(:ok)
|
||||||
expect(json).to include('body' => description.body, 'revision_id' => version.id)
|
expect(json).to include('body' => description.body, 'revision_id' => version.id)
|
||||||
@@ -223,6 +230,7 @@ RSpec.describe 'Tag description native API', type: :request do
|
|||||||
expect(description.version_no).to eq(1)
|
expect(description.version_no).to eq(1)
|
||||||
expect { native_write(body: 'after') }
|
expect { native_write(body: 'after') }
|
||||||
.to change(TagDescriptionVersion, :count).by(1)
|
.to change(TagDescriptionVersion, :count).by(1)
|
||||||
|
.and change { event_rows.length }.by(1)
|
||||||
expect(response).to have_http_status(:ok)
|
expect(response).to have_http_status(:ok)
|
||||||
expect(description.reload).to have_attributes(
|
expect(description.reload).to have_attributes(
|
||||||
body: 'after', version_no: 2,
|
body: 'after', version_no: 2,
|
||||||
@@ -234,6 +242,10 @@ RSpec.describe 'Tag description native API', type: :request do
|
|||||||
expect(version).to have_attributes(
|
expect(version).to have_attributes(
|
||||||
tag_id: tag.id, language_code: 'ja', version_no: 2, event_type: 'update',
|
tag_id: tag.id, language_code: 'ja', version_no: 2, event_type: 'update',
|
||||||
body: 'after', created_by_user_id: editor.id)
|
body: 'after', created_by_user_id: editor.id)
|
||||||
|
event = event_rows.last
|
||||||
|
expect(event).to include(
|
||||||
|
'event_type' => 'tag_descriptions.update', 'created_by_user_id' => editor.id)
|
||||||
|
expect(version.event_id).to eq(event.fetch('id'))
|
||||||
expect(revision.body).to eq(version.body)
|
expect(revision.body).to eq(version.body)
|
||||||
expect(description_versions(page).order(:version_no).last.body).to eq(version.body)
|
expect(description_versions(page).order(:version_no).last.body).to eq(version.body)
|
||||||
get "/wiki/#{ description.id }"
|
get "/wiki/#{ description.id }"
|
||||||
|
|||||||
@@ -121,10 +121,14 @@ RSpec.describe 'Tag group native API', type: :request do
|
|||||||
[:create, :update].each do |operation|
|
[:create, :update].each do |operation|
|
||||||
it "#{ operation }s structured data and keeps unplaced membership" do
|
it "#{ operation }s structured data and keeps unplaced membership" do
|
||||||
previous = group_version(group_id, 1)
|
previous = group_version(group_id, 1)
|
||||||
native_write(operation)
|
expect { native_write(operation) }.to change { event_rows.length }.by(1)
|
||||||
expect(response).to have_http_status(operation == :create ? :created : :ok)
|
expect(response).to have_http_status(operation == :create ? :created : :ok)
|
||||||
id = operation == :create ? json.fetch('id') : group_id
|
id = operation == :create ? json.fetch('id') : group_id
|
||||||
number = operation == :create ? 1 : 2
|
number = operation == :create ? 1 : 2
|
||||||
|
event = event_rows.last
|
||||||
|
event_type = operation == :create ? 'tag_groups.create' : 'tag_groups.update'
|
||||||
|
expect(event).to include('event_type' => event_type, 'created_by_user_id' => user.id)
|
||||||
|
expect(group_version(id, number).fetch('event_id')).to eq(event.fetch('id'))
|
||||||
expect_group_snapshot(
|
expect_group_snapshot(
|
||||||
id, number:, members: snapshot_members, placements:, description: '全体の説明')
|
id, number:, members: snapshot_members, placements:, description: '全体の説明')
|
||||||
expect(group_version(group_id, 1)).to eq(previous)
|
expect(group_version(group_id, 1)).to eq(previous)
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ RSpec.describe 'TagVersions API', type: :request do
|
|||||||
)
|
)
|
||||||
version =
|
version =
|
||||||
TagVersion.create!(
|
TagVersion.create!(
|
||||||
|
event_id: create_event!(event_type: 'unknown').id,
|
||||||
tag: tag,
|
tag: tag,
|
||||||
version_no: version_no,
|
version_no: version_no,
|
||||||
event_type: event_type,
|
event_type: event_type,
|
||||||
|
|||||||
@@ -705,6 +705,7 @@ RSpec.describe 'Tags API', type: :request do
|
|||||||
expect {
|
expect {
|
||||||
patch "/tags/#{tag.id}", params: { name: 'new_tag_name', category: 'meme' }
|
patch "/tags/#{tag.id}", params: { name: 'new_tag_name', category: 'meme' }
|
||||||
}.to change(TagVersion, :count).by(2)
|
}.to change(TagVersion, :count).by(2)
|
||||||
|
.and change { event_rows.length }.by(1)
|
||||||
|
|
||||||
expect(response).to have_http_status(:ok)
|
expect(response).to have_http_status(:ok)
|
||||||
|
|
||||||
@@ -719,6 +720,10 @@ RSpec.describe 'Tags API', type: :request do
|
|||||||
expect(version_primary_name(versions.second, 'ja')).to eq('new_tag_name')
|
expect(version_primary_name(versions.second, 'ja')).to eq('new_tag_name')
|
||||||
expect(versions.second.category).to eq('meme')
|
expect(versions.second.category).to eq('meme')
|
||||||
expect(versions.second.created_by_user_id).to eq(member_user.id)
|
expect(versions.second.created_by_user_id).to eq(member_user.id)
|
||||||
|
event = event_rows.last
|
||||||
|
expect(event).to include(
|
||||||
|
'event_type' => 'tags.update', 'created_by_user_id' => member_user.id)
|
||||||
|
expect(versions.pluck(:event_id).uniq).to eq([event.fetch('id')])
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'updates deprecated state and records it in tag versions' do
|
it 'updates deprecated state and records it in tag versions' do
|
||||||
@@ -1275,6 +1280,7 @@ RSpec.describe 'Tags API', type: :request do
|
|||||||
deprecated: '0',
|
deprecated: '0',
|
||||||
}
|
}
|
||||||
}.to change(TagVersion, :count).by(2)
|
}.to change(TagVersion, :count).by(2)
|
||||||
|
.and change { event_rows.length }.by(1)
|
||||||
|
|
||||||
expect(response).to have_http_status(:ok)
|
expect(response).to have_http_status(:ok)
|
||||||
|
|
||||||
@@ -1290,6 +1296,10 @@ RSpec.describe 'Tags API', type: :request do
|
|||||||
expect(versions.second.category).to eq('meta')
|
expect(versions.second.category).to eq('meta')
|
||||||
expect(version_alias_names(versions.second, 'ja')).to include('spec_tag')
|
expect(version_alias_names(versions.second, 'ja')).to include('spec_tag')
|
||||||
expect(versions.second.created_by_user_id).to eq(member_user.id)
|
expect(versions.second.created_by_user_id).to eq(member_user.id)
|
||||||
|
event = event_rows.last
|
||||||
|
expect(event).to include(
|
||||||
|
'event_type' => 'tags.update', 'created_by_user_id' => member_user.id)
|
||||||
|
expect(versions.pluck(:event_id).uniq).to eq([event.fetch('id')])
|
||||||
end
|
end
|
||||||
|
|
||||||
it '同じ tag の旧 alias へ戻しても rename できる' do
|
it '同じ tag の旧 alias へ戻しても rename できる' do
|
||||||
|
|||||||
@@ -143,7 +143,9 @@ RSpec.describe 'Counts export contracts' do
|
|||||||
expect(CountsExportDownload.find(token, 'owner')).not_to be_nil
|
expect(CountsExportDownload.find(token, 'owner')).not_to be_nil
|
||||||
expect(CountsExportDownload.find(token, 'other')).to be_nil
|
expect(CountsExportDownload.find(token, 'other')).to be_nil
|
||||||
post = create(:post)
|
post = create(:post)
|
||||||
PostVersion.create!(post:, version_no: 1, event_type: 'discard', url: post.url,
|
PostVersion.create!(
|
||||||
|
event_id: create_event!(event_type: 'unknown').id,
|
||||||
|
post:, version_no: 1, event_type: 'discard', url: post.url,
|
||||||
title: 'private', tags: '', tags_json: [], parent_post_ids: '', created_at: Time.utc(2024))
|
title: 'private', tags: '', tags_json: [], parent_post_ids: '', created_at: Time.utc(2024))
|
||||||
expect(CountsExportDownload.find(token, 'owner')).to be_nil
|
expect(CountsExportDownload.find(token, 'owner')).to be_nil
|
||||||
end
|
end
|
||||||
@@ -221,15 +223,9 @@ RSpec.describe CountsExportDownload::ResponseBody do
|
|||||||
body = described_class.new(source) { |state| states << state; true }
|
body = described_class.new(source) { |state| states << state; true }
|
||||||
response = ActionDispatch::Response.new
|
response = ActionDispatch::Response.new
|
||||||
response.body = body
|
response.body = body
|
||||||
|
deferred = response.stream.to_ary
|
||||||
_, _, rack_body = response.to_a
|
|
||||||
|
|
||||||
expect(states).to eq([])
|
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)
|
expect(states.count('started')).to eq(1)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -13,7 +13,9 @@ RSpec.describe 'Counts masked pivot contracts' do
|
|||||||
let!(:tags) do
|
let!(:tags) do
|
||||||
['A', 'B', 'C'].map do |name|
|
['A', 'B', 'C'].map do |name|
|
||||||
tag = create(:tag, primary_name: name)
|
tag = create(:tag, primary_name: name)
|
||||||
TagVersion.create!(tag:, version_no: 1, event_type: 'create', name:,
|
TagVersion.create!(
|
||||||
|
event_id: create_event!(event_type: 'unknown').id,
|
||||||
|
tag:, version_no: 1, event_type: 'create', name:,
|
||||||
category: 'general', aliases: '', parent_tag_ids: '',
|
category: 'general', aliases: '', parent_tag_ids: '',
|
||||||
names: { ja: { name:, script_code: 'Jpan', auto_generated: false, aliases: [] } },
|
names: { ja: { name:, script_code: 'Jpan', auto_generated: false, aliases: [] } },
|
||||||
created_at: Time.utc(2023))
|
created_at: Time.utc(2023))
|
||||||
@@ -25,12 +27,16 @@ RSpec.describe 'Counts masked pivot contracts' do
|
|||||||
[[0, 1], [0], [1, 2]].each_with_index do |indexes, index|
|
[[0, 1], [0], [1, 2]].each_with_index do |indexes, index|
|
||||||
post = create(:post)
|
post = create(:post)
|
||||||
snapshot = indexes.map { { tag_id: tags[_1].id, version_no: 1, sections: [] } }
|
snapshot = indexes.map { { tag_id: tags[_1].id, version_no: 1, sections: [] } }
|
||||||
PostVersion.create!(post:, version_no: 1, event_type: 'create',
|
PostVersion.create!(
|
||||||
|
event_id: create_event!(event_type: 'unknown').id,
|
||||||
|
post:, version_no: 1, event_type: 'create',
|
||||||
title: "hidden-title-#{ index }", url: post.url, tags: '', tags_json: snapshot,
|
title: "hidden-title-#{ index }", url: post.url, tags: '', tags_json: snapshot,
|
||||||
parent_post_ids: '', created_by_user: member, created_at: Time.utc(2024, 1))
|
parent_post_ids: '', created_by_user: member, created_at: Time.utc(2024, 1))
|
||||||
next if index == 2
|
next if index == 2
|
||||||
|
|
||||||
PostVersion.create!(post:, version_no: 2, event_type: 'discard',
|
PostVersion.create!(
|
||||||
|
event_id: create_event!(event_type: 'unknown').id,
|
||||||
|
post:, version_no: 2, event_type: 'discard',
|
||||||
title: "private-discard-#{ index }", url: post.url, tags: '', tags_json: [],
|
title: "private-discard-#{ index }", url: post.url, tags: '', tags_json: [],
|
||||||
parent_post_ids: '', created_by_user: member, created_at: Time.utc(2024, 2))
|
parent_post_ids: '', created_by_user: member, created_at: Time.utc(2024, 2))
|
||||||
end
|
end
|
||||||
@@ -69,29 +75,27 @@ RSpec.describe 'Counts masked pivot contracts' do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'counts one opaque collection per private version across pages' do
|
it 'preserves five tag occurrences after merging private tags and across pages' do
|
||||||
[nil, guest, member, admin].each do |user|
|
[nil, guest, member, admin].each do |user|
|
||||||
page = result(user)
|
page = result(user)
|
||||||
expected = user == admin ? 5 : 6
|
expect(grand(page)).to eq(5)
|
||||||
expect(grand(page)).to eq(expected)
|
expect(page[:cells].sum { _1[:count] }).to eq(5)
|
||||||
expect(page[:cells].sum { _1[:count] }).to eq(expected)
|
|
||||||
expect(page).not_to have_key(:excluded_count)
|
expect(page).not_to have_key(:excluded_count)
|
||||||
paged = result(user, 'limit' => '1')
|
paged = result(user, 'limit' => '1')
|
||||||
expect(grand(paged)).to eq(expected)
|
expect(grand(paged)).to eq(5)
|
||||||
expect(result(user, 'limit' => '1', 'page' => '2')[:group_count])
|
expect(result(user, 'limit' => '1', 'page' => '2')[:group_count])
|
||||||
.to eq(page[:group_count])
|
.to eq(page[:group_count])
|
||||||
end
|
end
|
||||||
public_page = result(nil)
|
public_page = result(nil)
|
||||||
hidden = public_page[:cells].select { _1[:values].first == CountsAxes::MASKED_VALUE }
|
hidden = public_page[:cells].select { _1[:values].first == CountsAxes::MASKED_VALUE }
|
||||||
expect(hidden.sum { _1[:count] }).to eq(4)
|
expect(hidden.sum { _1[:count] }).to eq(3)
|
||||||
expect(public_page[:axis_labels]['a0']).not_to have_key("internal:#{ tags.first.id }")
|
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')
|
expect(public_page.to_json).not_to include('hidden-title', 'private-discard')
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'retains distinct membership when tags are not a row axis' do
|
it 'retains distinct membership when tags are not a row axis' do
|
||||||
[nil, admin].each do |user|
|
[nil, admin].each do |user|
|
||||||
expect(grand(result(user, 'rows' => 'event_type', 'cols' => 'tags')))
|
expect(grand(result(user, 'rows' => 'event_type', 'cols' => 'tags'))).to eq(3)
|
||||||
.to eq(user == admin ? 3 : 5)
|
|
||||||
expect(result(user, 'rows' => '', 'cols' => '')[:cells].sole[:count]).to eq(5)
|
expect(result(user, 'rows' => '', 'cols' => '')[:cells].sole[:count]).to eq(5)
|
||||||
details = result(user, 'mode' => 'details', 'rows' => '', 'cols' => '')
|
details = result(user, 'mode' => 'details', 'rows' => '', 'cols' => '')
|
||||||
expect(details[:count]).to eq(user == admin ? 5 : 1)
|
expect(details[:count]).to eq(user == admin ? 5 : 1)
|
||||||
@@ -100,12 +104,16 @@ RSpec.describe 'Counts masked pivot contracts' do
|
|||||||
|
|
||||||
it 'uses changed-record membership for totals and hides changes to private predecessors' do
|
it 'uses changed-record membership for totals and hides changes to private predecessors' do
|
||||||
post = PostVersion.order(:id).last.post
|
post = PostVersion.order(:id).last.post
|
||||||
PostVersion.create!(post:, version_no: 2, event_type: 'update', title: 'changed-public-title',
|
PostVersion.create!(
|
||||||
|
event_id: create_event!(event_type: 'unknown').id,
|
||||||
|
post:, version_no: 2, event_type: 'update', title: 'changed-public-title',
|
||||||
url: post.url, tags: '',
|
url: post.url, tags: '',
|
||||||
tags_json: [{ tag_id: tags.last.id, version_no: 1, sections: [] }],
|
tags_json: [{ tag_id: tags.last.id, version_no: 1, sections: [] }],
|
||||||
parent_post_ids: '', created_at: Time.utc(2024, 3))
|
parent_post_ids: '', created_at: Time.utc(2024, 3))
|
||||||
restored = PostVersion.order(:id).first.post
|
restored = PostVersion.order(:id).first.post
|
||||||
PostVersion.create!(post: restored, version_no: 3, event_type: 'restore',
|
PostVersion.create!(
|
||||||
|
event_id: create_event!(event_type: 'unknown').id,
|
||||||
|
post: restored, version_no: 3, event_type: 'restore',
|
||||||
title: 'restored-public-title', url: restored.url, tags: '',
|
title: 'restored-public-title', url: restored.url, tags: '',
|
||||||
tags_json: [{ tag_id: tags.first.id, version_no: 1, sections: [] }], parent_post_ids: '',
|
tags_json: [{ tag_id: tags.first.id, version_no: 1, sections: [] }], parent_post_ids: '',
|
||||||
created_at: Time.utc(2024, 4))
|
created_at: Time.utc(2024, 4))
|
||||||
@@ -123,19 +131,22 @@ RSpec.describe 'Counts masked pivot contracts' do
|
|||||||
it 'counts distinct changed records when tags are not additive row tuples' do
|
it 'counts distinct changed records when tags are not additive row tuples' do
|
||||||
[nil, admin].each do |user|
|
[nil, admin].each do |user|
|
||||||
page = result(user, 'rows' => 'changed_field', 'cols' => 'event_type')
|
page = result(user, 'rows' => 'changed_field', 'cols' => 'event_type')
|
||||||
expect(grand(page)).to eq(user == admin ? 2 : 4)
|
expect(grand(page)).to eq(2)
|
||||||
if user.nil?
|
if user.nil?
|
||||||
expect(page[:cells].map { _1[:values].first }.uniq).to eq([CountsAxes::MASKED_VALUE])
|
expect(page[:cells].map { _1[:values].first }.uniq).to eq([CountsAxes::MASKED_VALUE])
|
||||||
end
|
end
|
||||||
# Private empty collections contribute the same opaque tuple as populated ones.
|
# Changed discard snapshots have no tags: no full cell contributes here.
|
||||||
combined = result(user, 'rows' => 'changed_field', 'cols' => 'tags')
|
empty = result(user, 'rows' => 'changed_field', 'cols' => 'tags')
|
||||||
expect(grand(combined)).to eq(user == admin ? 0 : 4)
|
expect(empty[:cells]).to be_empty
|
||||||
|
expect(grand(empty)).to eq(0)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'keeps NULL as a public data value rather than treating it as a rollup' do
|
it 'keeps NULL as a public data value rather than treating it as a rollup' do
|
||||||
post = create(:post)
|
post = create(:post)
|
||||||
PostVersion.create!(post:, version_no: 1, event_type: 'create', title: 'public bot',
|
PostVersion.create!(
|
||||||
|
event_id: create_event!(event_type: 'unknown').id,
|
||||||
|
post:, version_no: 1, event_type: 'create', title: 'public bot',
|
||||||
url: post.url, tags: '', tags_json: [], parent_post_ids: '', created_at: Time.utc(2024, 5))
|
url: post.url, tags: '', tags_json: [], parent_post_ids: '', created_at: Time.utc(2024, 5))
|
||||||
page = result(nil, 'rows' => 'user', 'cols' => 'event_type')
|
page = result(nil, 'rows' => 'user', 'cols' => 'event_type')
|
||||||
expect(page[:cells].select { _1[:values].first.nil? }.sum { _1[:count] }).to eq(1)
|
expect(page[:cells].select { _1[:values].first.nil? }.sum { _1[:count] }).to eq(1)
|
||||||
@@ -143,205 +154,38 @@ RSpec.describe 'Counts masked pivot contracts' do
|
|||||||
expect(page[:row_headers]).to include(hash_including(values: [nil], kind: 'data'))
|
expect(page[:row_headers]).to include(hash_including(values: [nil], kind: 'data'))
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'sorts public version numbers numerically including formerly hidden records' do
|
it 'sorts public numeric values numerically alongside a masked group' do
|
||||||
post = PostVersion.order(:id).last.post
|
post = PostVersion.order(:id).last.post
|
||||||
[2, 10].each do |number|
|
[2, 10].each do |number|
|
||||||
PostVersion.create!(post:, version_no: number, event_type: 'update',
|
PostVersion.create!(
|
||||||
|
event_id: create_event!(event_type: 'unknown').id,
|
||||||
|
post:, version_no: number, event_type: 'update',
|
||||||
title: 'public numeric sort', url: post.url, tags: '', tags_json: [],
|
title: 'public numeric sort', url: post.url, tags: '', tags_json: [],
|
||||||
parent_post_ids: '', created_at: Time.utc(2024, 5))
|
parent_post_ids: '', created_at: Time.utc(2024, 5))
|
||||||
end
|
end
|
||||||
page = result(nil, 'rows' => 'version_no', 'cols' => '')
|
page = result(nil, 'rows' => 'version_no', 'cols' => '')
|
||||||
values = page[:row_headers].select { _1[:kind] == 'data' }.map { _1[:values].first }
|
values = page[:row_headers].select { _1[:kind] == 'data' }.map { _1[:values].first }
|
||||||
expect(values.map(&:to_i)).to eq([1, 2, 10])
|
expect(values.reject { _1 == CountsAxes::MASKED_VALUE }.map(&:to_i)).to eq([1, 2, 10])
|
||||||
|
expect(values.last).to eq(CountsAxes::MASKED_VALUE)
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'keeps public identity predicates and excludes private attributes' do
|
it 'rejects private predicates and supplied measure coordinates before SQL labels escape' do
|
||||||
hidden_id = PostVersion.first.post_id.to_s
|
probes = [{ 'resource_id' => PostVersion.first.post_id.to_s },
|
||||||
base = { 'rows' => '', 'cols' => '' }
|
{ 'tags' => 'A' },
|
||||||
condition = lambda do |field, operator, value|
|
{ 'conditions' => '', 'conditions.0.field' => 'title', 'conditions.0.operator' => 'eq',
|
||||||
{ 'conditions' => '', 'conditions.0.field' => field,
|
'conditions.0.value' => 'hidden-title-0' },
|
||||||
'conditions.0.operator' => operator, 'conditions.0.value' => value }
|
{ 'drill' => '', 'drill.0.field' => 'tags', 'drill.0.value_type' => 'string',
|
||||||
end
|
'drill.0.value' => "internal:#{ tags.first.id }" },
|
||||||
|
|
||||||
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',
|
{ 'cols' => 'tags', 'column_order' => '', 'column_order.0.value_type' => 'string',
|
||||||
'column_order.0.value' => CountsAxes::MASKED_VALUE }]
|
'column_order.0.value' => CountsAxes::MASKED_VALUE }]
|
||||||
HistoryAccess.set(user: nil) do
|
HistoryAccess.set(user: nil) do
|
||||||
probes.each do |probe|
|
probes.each do |probe|
|
||||||
expect { CountsTableQuery.new(params.merge(probe)).call }
|
expect { CountsTableQuery.new(params.merge(probe)) }
|
||||||
.to raise_error(CountsAxes::PrivateCondition)
|
.to raise_error(CountsAxes::PrivateCondition)
|
||||||
end
|
end
|
||||||
end
|
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
|
it 'keeps public column sorting usable while guessed private coordinates match nothing' do
|
||||||
first = result(nil, 'rows' => 'event_type', 'cols' => 'tags')
|
first = result(nil, 'rows' => 'event_type', 'cols' => 'tags')
|
||||||
probe = result(nil, 'rows' => 'event_type', 'cols' => 'tags',
|
probe = result(nil, 'rows' => 'event_type', 'cols' => 'tags',
|
||||||
@@ -357,7 +201,7 @@ RSpec.describe 'Counts masked pivot contracts' do
|
|||||||
HistoryAccess.set(user: nil) do
|
HistoryAccess.set(user: nil) do
|
||||||
payload = params.merge('format' => 'json')
|
payload = params.merge('format' => 'json')
|
||||||
json = JSON.parse(CountsTableExport.new(payload).body.to_a.join)
|
json = JSON.parse(CountsTableExport.new(payload).body.to_a.join)
|
||||||
expect(json['data'].sum { _1['kind'] == 'data' ? _1['count'] : 0 }).to eq(6)
|
expect(json['data'].sum { _1['kind'] == 'data' ? _1['count'] : 0 }).to eq(5)
|
||||||
expect(json.to_json).not_to include('hidden-title', 'private-discard')
|
expect(json.to_json).not_to include('hidden-title', 'private-discard')
|
||||||
csv = CountsTableExport.new(payload.merge('format' => 'csv')).body.to_a.join
|
csv = CountsTableExport.new(payload.merge('format' => 'csv')).body.to_a.join
|
||||||
expect(csv).to include('********')
|
expect(csv).to include('********')
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ RSpec.describe MaterialSyncImporter do
|
|||||||
profile: 'legacy_drive',
|
profile: 'legacy_drive',
|
||||||
export_path: '素材/a.png',
|
export_path: '素材/a.png',
|
||||||
created_by_user: user)
|
created_by_user: user)
|
||||||
|
events = event_rows
|
||||||
|
|
||||||
result =
|
result =
|
||||||
described_class.import!(
|
described_class.import!(
|
||||||
@@ -73,6 +74,7 @@ RSpec.describe MaterialSyncImporter do
|
|||||||
expect(result.action).to eq(:unchanged)
|
expect(result.action).to eq(:unchanged)
|
||||||
expect(material.reload.tag).to eq(tag)
|
expect(material.reload.tag).to eq(tag)
|
||||||
expect(material.url).to eq('https://example.com/human')
|
expect(material.url).to eq('https://example.com/human')
|
||||||
|
expect(event_rows).to eq(events)
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'suppresses a Google Drive candidate by relative path prefix' do
|
it 'suppresses a Google Drive candidate by relative path prefix' do
|
||||||
|
|||||||
@@ -14,6 +14,47 @@ RSpec.describe MaterialSyncRunner do
|
|||||||
let(:drive_client) { instance_double(GoogleDrive::ApiClient) }
|
let(:drive_client) { instance_double(GoogleDrive::ApiClient) }
|
||||||
|
|
||||||
describe '#sync!' do
|
describe '#sync!' do
|
||||||
|
it 'groups created and updated materials across sync sources into one event' do
|
||||||
|
existing = Material.create!(
|
||||||
|
source_kind: 'google_drive_file', source_file_id: 'existing-file',
|
||||||
|
source_path: 'old.png', url: 'https://example.com/existing',
|
||||||
|
created_by_user: user, updated_by_user: user)
|
||||||
|
source
|
||||||
|
MaterialSyncSource.create!(
|
||||||
|
name: 'Second Drive source', source_kind: 'google_drive_path',
|
||||||
|
source_file_id: 'folder-456', profile: 'legacy_drive',
|
||||||
|
created_by_user: user, updated_by_user: user)
|
||||||
|
allow(GoogleDrive::ApiClient).to receive(:new).and_return(drive_client)
|
||||||
|
allow(drive_client).to receive(:each_material_file_under_folder) do |folder_id, &block|
|
||||||
|
id = folder_id == 'folder-123' ? 'existing-file' : 'new-file'
|
||||||
|
block.call(id:, name: "#{ id }.png", mime_type: 'image/png',
|
||||||
|
relative_path: "素材/#{ id }.png", sha256_checksum: nil,
|
||||||
|
web_view_link: "https://drive.google.com/file/d/#{ id }/view")
|
||||||
|
end
|
||||||
|
allow(drive_client).to receive(:download_to_tempfile) do |id, **|
|
||||||
|
Tempfile.new(['sync-event', '.png']).tap do |file|
|
||||||
|
file.binmode
|
||||||
|
file.write(id)
|
||||||
|
file.rewind
|
||||||
|
end
|
||||||
|
end
|
||||||
|
allow(MaterialThumbnailGenerator).to receive(:generate!)
|
||||||
|
previous_ids = MaterialVersion.pluck(:id)
|
||||||
|
|
||||||
|
result = nil
|
||||||
|
expect { result = described_class.sync_enabled! }
|
||||||
|
.to change { event_rows.length }.by(1)
|
||||||
|
|
||||||
|
expect(result).to have_attributes(imported: 1, updated: 1, failed: 0)
|
||||||
|
event = event_rows.last
|
||||||
|
expect(event.fetch('event_type')).to eq('materials.sync')
|
||||||
|
created = Material.find_by!(source_file_id: 'new-file')
|
||||||
|
versions = MaterialVersion.where.not(id: previous_ids)
|
||||||
|
expect(versions.pluck(:material_id).uniq).to contain_exactly(existing.id, created.id)
|
||||||
|
expect(versions.pluck(:event_type)).to include('create', 'update')
|
||||||
|
expect(versions.pluck(:event_id).uniq).to eq([event.fetch('id')])
|
||||||
|
end
|
||||||
|
|
||||||
it 'imports google drive path candidates as they are yielded' do
|
it 'imports google drive path candidates as they are yielded' do
|
||||||
first_entry = { id: 'file-1',
|
first_entry = { id: 'file-1',
|
||||||
name: 'a.png',
|
name: 'a.png',
|
||||||
|
|||||||
@@ -88,3 +88,86 @@ RSpec.describe PostBulkCreator do
|
|||||||
expect(creator).to have_received(:create!).with(locale).once
|
expect(creator).to have_received(:create!).with(locale).once
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
RSpec.describe 'PostBulkCreator event grouping', type: :model do
|
||||||
|
self.use_transactional_tests = false
|
||||||
|
|
||||||
|
before do
|
||||||
|
expect(Rails.env.test?).to be(true)
|
||||||
|
expect(ApplicationRecord.connection.open_transactions).to eq(0)
|
||||||
|
@prefix = "bulk_event_#{ SecureRandom.hex(8) }"
|
||||||
|
@actor = create(:user, :member)
|
||||||
|
allow(PostCreatePreflight).to receive(:new) do |attributes:, **|
|
||||||
|
specs = [{ name: attributes.fetch(:title), category: :general }]
|
||||||
|
instance_double(PostCreatePreflight, run: attributes.merge(
|
||||||
|
existing_post_id: nil, snapshot_tag_specs: specs, post_tag_specs: specs,
|
||||||
|
normalised_parent_post_ids: [], tag_sections: { }, video_ms: nil))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
after do
|
||||||
|
if @actor
|
||||||
|
posts = Post.where(uploaded_user: @actor)
|
||||||
|
PostTagSection.where(post_id: posts.select(:id)).delete_all
|
||||||
|
PostTag.where(post_id: posts.select(:id)).delete_all
|
||||||
|
PostVersion.where(post_id: posts.select(:id)).delete_all
|
||||||
|
posts.delete_all
|
||||||
|
tag_ids = TagName.where(name: ["#{ @prefix }_1", "#{ @prefix }_2"]).pluck(:tag_id)
|
||||||
|
TagVersion.where(tag_id: tag_ids).delete_all
|
||||||
|
TagName.where(tag_id: tag_ids).delete_all
|
||||||
|
Tag.where(id: tag_ids).delete_all
|
||||||
|
EventRecords::EventRow.where(created_by_user_id: @actor.id).delete_all
|
||||||
|
@actor.delete
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'shares one creation event between real workers and their tag histories' do
|
||||||
|
posts = [1, 2].map do |number|
|
||||||
|
{ title: "#{ @prefix }_#{ number }",
|
||||||
|
url: "https://example.com/#{ @prefix }/#{ number }" }
|
||||||
|
end
|
||||||
|
|
||||||
|
result = nil
|
||||||
|
expect {
|
||||||
|
result = PostBulkCreator.new(actor: @actor, posts:, thumbnails: { })
|
||||||
|
.run(Locale.nipponese)
|
||||||
|
}.to change { event_rows.length }.by(1)
|
||||||
|
|
||||||
|
expect(result.fetch(:results).map { _1.fetch(:status) }).to eq(['created', 'created'])
|
||||||
|
event = event_rows.last
|
||||||
|
expect(event).to include(
|
||||||
|
'event_type' => 'posts.create', 'created_by_user_id' => @actor.id)
|
||||||
|
versions = PostVersion.where(created_by_user_id: @actor.id)
|
||||||
|
expect(versions.count).to eq(2)
|
||||||
|
expect(versions.pluck(:event_id).uniq).to eq([event.fetch('id')])
|
||||||
|
tag_versions = TagVersion.where(created_by_user_id: @actor.id)
|
||||||
|
expect(tag_versions.count).to eq(2)
|
||||||
|
expect(tag_versions.pluck(:event_id).uniq).to eq([event.fetch('id')])
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'does not leave an event when every row fails validation' do
|
||||||
|
preflight = instance_double(PostCreatePreflight)
|
||||||
|
allow(PostCreatePreflight).to receive(:new).and_return(preflight)
|
||||||
|
allow(preflight).to receive(:run).and_raise(
|
||||||
|
PostCreatePreflight::ValidationFailed.new(fields: { url: ['invalid'] }))
|
||||||
|
events = event_rows
|
||||||
|
|
||||||
|
result = PostBulkCreator.new(
|
||||||
|
actor: @actor, posts: [{ title: 'invalid' }], thumbnails: { }).run(Locale.nipponese)
|
||||||
|
|
||||||
|
expect(result.fetch(:results).map { _1.fetch(:status) }).to eq(['failed'])
|
||||||
|
expect(event_rows).to eq(events)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'does not leave an event when every row is skipped' do
|
||||||
|
preflight = instance_double(PostCreatePreflight, run: { existing_post_id: 123 })
|
||||||
|
allow(PostCreatePreflight).to receive(:new).and_return(preflight)
|
||||||
|
events = event_rows
|
||||||
|
|
||||||
|
result = PostBulkCreator.new(
|
||||||
|
actor: @actor, posts: [{ title: 'existing' }], thumbnails: { }).run(Locale.nipponese)
|
||||||
|
|
||||||
|
expect(result.fetch(:results).map { _1.fetch(:status) }).to eq(['skipped'])
|
||||||
|
expect(event_rows).to eq(events)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ RSpec.describe 'Shared resource IDs across MySQL connections', type: :model do
|
|||||||
@tag = create(:tag, primary_name: "#{ @prefix }_owner")
|
@tag = create(:tag, primary_name: "#{ @prefix }_owner")
|
||||||
@group = TagGroup.create!(name: "#{ @prefix }_owner", version_no: 1)
|
@group = TagGroup.create!(name: "#{ @prefix }_owner", version_no: 1)
|
||||||
@tag_ids = Queue.new
|
@tag_ids = Queue.new
|
||||||
|
@event_ids = Queue.new
|
||||||
@threads = []
|
@threads = []
|
||||||
@release = Queue.new
|
@release = Queue.new
|
||||||
end
|
end
|
||||||
@@ -42,6 +43,9 @@ RSpec.describe 'Shared resource IDs across MySQL connections', type: :model do
|
|||||||
ids << @tag.id if @tag
|
ids << @tag.id if @tag
|
||||||
TagName.where(tag_id: ids).delete_all
|
TagName.where(tag_id: ids).delete_all
|
||||||
Tag.where(id: ids).delete_all
|
Tag.where(id: ids).delete_all
|
||||||
|
event_ids = []
|
||||||
|
event_ids << @event_ids.pop until @event_ids.empty? if @event_ids
|
||||||
|
EventRecords::EventRow.where(id: event_ids).delete_all
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -72,11 +76,17 @@ RSpec.describe 'Shared resource IDs across MySQL connections', type: :model do
|
|||||||
when :description
|
when :description
|
||||||
TagDescription.create!(tag: @tag, language_code: 'ja', body: '', version_no: 1)
|
TagDescription.create!(tag: @tag, language_code: 'ja', body: '', version_no: 1)
|
||||||
when :description_version
|
when :description_version
|
||||||
|
event = create_event!(event_type: 'unknown')
|
||||||
|
@event_ids << event.id
|
||||||
TagDescriptionVersion.create!(
|
TagDescriptionVersion.create!(
|
||||||
|
event_id: event.id,
|
||||||
tag_id: @tag.id, language_code: 'ja', body: 'body', version_no: 1,
|
tag_id: @tag.id, language_code: 'ja', body: 'body', version_no: 1,
|
||||||
event_type: 'create', created_at: Time.current)
|
event_type: 'create', created_at: Time.current)
|
||||||
when :group_version
|
when :group_version
|
||||||
|
event = create_event!(event_type: 'unknown')
|
||||||
|
@event_ids << event.id
|
||||||
TagGroupVersion.create!(
|
TagGroupVersion.create!(
|
||||||
|
event_id: event.id,
|
||||||
tag_group: @group, name: @group.name, version_no: 1, event_type: 'create',
|
tag_group: @group, name: @group.name, version_no: 1, event_type: 'create',
|
||||||
members: [], placements: [], created_at: Time.current)
|
members: [], placements: [], created_at: Time.current)
|
||||||
when :group
|
when :group
|
||||||
|
|||||||
@@ -15,10 +15,12 @@ RSpec.describe SharedResourceIdAllocator do
|
|||||||
TagGroup.new(id:, name: "group #{ number }", version_no: number)
|
TagGroup.new(id:, name: "group #{ number }", version_no: number)
|
||||||
when 'tag_description_versions'
|
when 'tag_description_versions'
|
||||||
TagDescriptionVersion.new(
|
TagDescriptionVersion.new(
|
||||||
|
event_id: create_event!(event_type: 'unknown').id,
|
||||||
id:, tag_id: tag.id, language_code: 'ja', body: 'body', version_no: number,
|
id:, tag_id: tag.id, language_code: 'ja', body: 'body', version_no: number,
|
||||||
event_type: 'create', created_at: Time.current)
|
event_type: 'create', created_at: Time.current)
|
||||||
when 'tag_group_versions'
|
when 'tag_group_versions'
|
||||||
TagGroupVersion.new(
|
TagGroupVersion.new(
|
||||||
|
event_id: create_event!(event_type: 'unknown').id,
|
||||||
id:, tag_group_id: group.id, name: group.name, version_no: number,
|
id:, tag_group_id: group.id, name: group.name, version_no: number,
|
||||||
event_type: 'create', members: [], placements: [], created_at: Time.current)
|
event_type: 'create', members: [], placements: [], created_at: Time.current)
|
||||||
end
|
end
|
||||||
@@ -82,6 +84,7 @@ RSpec.describe SharedResourceIdAllocator do
|
|||||||
it 'reserves a Tag ID referenced only by remaining description history' do
|
it 'reserves a Tag ID referenced only by remaining description history' do
|
||||||
high = [Tag.maximum(:id).to_i, ExternalTag.maximum(:id).to_i].max + 1000
|
high = [Tag.maximum(:id).to_i, ExternalTag.maximum(:id).to_i].max + 1000
|
||||||
TagDescriptionVersion.create!(
|
TagDescriptionVersion.create!(
|
||||||
|
event_id: create_event!(event_type: 'unknown').id,
|
||||||
tag_id: high, language_code: 'ja', version_no: 1, body: 'history',
|
tag_id: high, language_code: 'ja', version_no: 1, body: 'history',
|
||||||
event_type: 'create', created_at: Time.current)
|
event_type: 'create', created_at: Time.current)
|
||||||
expect(create(:tag).id).to be > high
|
expect(create(:tag).id).to be > high
|
||||||
|
|||||||
@@ -42,6 +42,41 @@ RSpec.describe Youtube::Sync do
|
|||||||
end
|
end
|
||||||
|
|
||||||
describe '#sync!' do
|
describe '#sync!' do
|
||||||
|
it 'groups created and updated post histories into one sync event' do
|
||||||
|
allow(PostVersionRecorder).to receive(:record!).and_call_original
|
||||||
|
allow(PostVersionRecorder).to receive(:ensure_snapshot!).and_call_original
|
||||||
|
existing = create(:post, url: 'https://youtu.be/video-1', title: 'old title')
|
||||||
|
allow(sync).to receive(:query_terms).and_return([])
|
||||||
|
allow(sync).to receive(:playlist_ids).and_return(['PL123'])
|
||||||
|
allow(client).to receive(:playlist_items).with(playlist_id: 'PL123', page_token: nil)
|
||||||
|
.and_return('items' => [
|
||||||
|
{ 'contentDetails' => { 'videoId' => 'video-1' } },
|
||||||
|
{ 'contentDetails' => { 'videoId' => 'video-2' } }])
|
||||||
|
allow(client).to receive(:videos).with(['video-1', 'video-2'])
|
||||||
|
.and_return('items' => [
|
||||||
|
youtube_video_item(id: 'video-1', title: 'updated', channel_id: 'UC_NONE'),
|
||||||
|
youtube_video_item(id: 'video-2', title: 'created', channel_id: 'UC_NONE')])
|
||||||
|
previous_post_ids = PostVersion.pluck(:id)
|
||||||
|
previous_tag_ids = TagVersion.pluck(:id)
|
||||||
|
|
||||||
|
expect { sync.sync! }.to change { event_rows.length }.by(1)
|
||||||
|
|
||||||
|
event = event_rows.last
|
||||||
|
expect(event).to include('event_type' => 'posts.sync', 'created_by_user_id' => nil)
|
||||||
|
versions = PostVersion.where.not(id: previous_post_ids)
|
||||||
|
created = Post.find_by!(url: 'https://www.youtube.com/watch?v=video-2')
|
||||||
|
expect(versions.pluck(:post_id).uniq).to contain_exactly(existing.id, created.id)
|
||||||
|
expect(versions.pluck(:event_type)).to include('create', 'update')
|
||||||
|
expect(versions.pluck(:event_id).uniq).to eq([event.fetch('id')])
|
||||||
|
tag_versions = TagVersion.where.not(id: previous_tag_ids)
|
||||||
|
expect(tag_versions).not_to be_empty
|
||||||
|
expect(tag_versions.pluck(:event_id).uniq).to eq([event.fetch('id')])
|
||||||
|
|
||||||
|
events = event_rows
|
||||||
|
expect { sync.sync! }.not_to change(PostVersion, :count)
|
||||||
|
expect(event_rows).to eq(events)
|
||||||
|
end
|
||||||
|
|
||||||
it 'returns without fetching video details when no video ids are discovered' do
|
it 'returns without fetching video details when no video ids are discovered' do
|
||||||
allow(sync).to receive(:query_terms).and_return([])
|
allow(sync).to receive(:query_terms).and_return([])
|
||||||
allow(sync).to receive(:playlist_ids).and_return([])
|
allow(sync).to receive(:playlist_ids).and_return([])
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
module EventRecords
|
||||||
|
# Read the database contract without requiring an application Event model.
|
||||||
|
class EventRow < ActiveRecord::Base
|
||||||
|
self.table_name = 'events'
|
||||||
|
end
|
||||||
|
|
||||||
|
def event_rows
|
||||||
|
EventRow.order(:id).map(&:attributes)
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_event! event_type:, created_by_user: nil
|
||||||
|
EventRow.create!(event_type:, created_by_user_id: created_by_user&.id,
|
||||||
|
created_at: Time.current)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -3,11 +3,13 @@ require 'cgi'
|
|||||||
# SQL fixtures use the migration contract without inventing application models.
|
# SQL fixtures use the migration contract without inventing application models.
|
||||||
# Include explicitly; these helpers must not affect unrelated examples.
|
# Include explicitly; these helpers must not affect unrelated examples.
|
||||||
module TagGroupRecords
|
module TagGroupRecords
|
||||||
|
include EventRecords
|
||||||
|
|
||||||
GROUP_TABLES = [
|
GROUP_TABLES = [
|
||||||
'tag_groups', 'tag_group_tags', 'tag_group_groups',
|
'tag_groups', 'tag_group_tags', 'tag_group_groups',
|
||||||
'tag_group_placements', 'tag_group_versions'].freeze
|
'tag_group_placements', 'tag_group_versions'].freeze
|
||||||
WRITE_TABLES = GROUP_TABLES + [
|
WRITE_TABLES = GROUP_TABLES + [
|
||||||
'tag_names', 'tag_description_versions',
|
'events', 'tag_names', 'tag_description_versions',
|
||||||
'tag_descriptions']
|
'tag_descriptions']
|
||||||
|
|
||||||
def group_connection = ApplicationRecord.connection
|
def group_connection = ApplicationRecord.connection
|
||||||
@@ -114,6 +116,7 @@ module TagGroupRecords
|
|||||||
group_id = insert_group_row('tag_groups', attributes)
|
group_id = insert_group_row('tag_groups', attributes)
|
||||||
insert_group_row('tag_group_versions', {
|
insert_group_row('tag_group_versions', {
|
||||||
tag_group_id: group_id, version_no: 1, event_type: 'create',
|
tag_group_id: group_id, version_no: 1, event_type: 'create',
|
||||||
|
event_id: create_event!(event_type: 'tag_groups.create', created_by_user: user).id,
|
||||||
name:, description:, members: members.to_json, placements: placements.to_json,
|
name:, description:, members: members.to_json, placements: placements.to_json,
|
||||||
created_by_user_id: user.id, created_at: now })
|
created_by_user_id: user.id, created_at: now })
|
||||||
members.each do |member|
|
members.each do |member|
|
||||||
|
|||||||
@@ -174,6 +174,7 @@ RSpec.describe 'nico:sync' do
|
|||||||
|
|
||||||
def create_post_version_for!(post, version_no: 1, event_type: 'create', created_by_user: nil)
|
def create_post_version_for!(post, version_no: 1, event_type: 'create', created_by_user: nil)
|
||||||
PostVersion.create!(
|
PostVersion.create!(
|
||||||
|
event_id: create_event!(event_type: 'unknown').id,
|
||||||
post: post,
|
post: post,
|
||||||
version_no: version_no,
|
version_no: version_no,
|
||||||
event_type: event_type,
|
event_type: event_type,
|
||||||
@@ -186,8 +187,7 @@ RSpec.describe 'nico:sync' do
|
|||||||
original_created_from: post.original_created_from,
|
original_created_from: post.original_created_from,
|
||||||
original_created_before: post.original_created_before,
|
original_created_before: post.original_created_before,
|
||||||
created_at: Time.current,
|
created_at: Time.current,
|
||||||
created_by_user: created_by_user
|
created_by_user: created_by_user)
|
||||||
)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
it '新規 post 作成時に version 1 を作る' do
|
it '新規 post 作成時に version 1 を作る' do
|
||||||
@@ -209,6 +209,7 @@ RSpec.describe 'nico:sync' do
|
|||||||
expect {
|
expect {
|
||||||
run_rake_task('nico:sync')
|
run_rake_task('nico:sync')
|
||||||
}.to change(PostVersion, :count).by(1)
|
}.to change(PostVersion, :count).by(1)
|
||||||
|
.and change { event_rows.length }.by(1)
|
||||||
|
|
||||||
post = Post.find_by!(url: 'https://www.nicovideo.jp/watch/sm9')
|
post = Post.find_by!(url: 'https://www.nicovideo.jp/watch/sm9')
|
||||||
version = post.post_versions.order(:version_no).last
|
version = post.post_versions.order(:version_no).last
|
||||||
@@ -217,6 +218,32 @@ RSpec.describe 'nico:sync' do
|
|||||||
expect(version.event_type).to eq('create')
|
expect(version.event_type).to eq('create')
|
||||||
expect(version.created_by_user).to be_nil
|
expect(version.created_by_user).to be_nil
|
||||||
expect(version.tags).to eq(snapshot_tags(post.reload))
|
expect(version.tags).to eq(snapshot_tags(post.reload))
|
||||||
|
event = event_rows.last
|
||||||
|
expect(event).to include('event_type' => 'posts.sync', 'created_by_user_id' => nil)
|
||||||
|
expect(version.event_id).to eq(event.fetch('id'))
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'groups created and updated post histories into one sync event' do
|
||||||
|
existing = create(:post, title: 'old', url: 'https://www.nicovideo.jp/watch/sm9')
|
||||||
|
stub_python([
|
||||||
|
{ 'code' => 'sm9', 'title' => 'updated', 'tags' => [] },
|
||||||
|
{ 'code' => 'sm10', 'title' => 'created', 'tags' => [] }])
|
||||||
|
allow(URI).to receive(:open).and_return(StringIO.new('<html></html>'))
|
||||||
|
previous_post_ids = PostVersion.pluck(:id)
|
||||||
|
previous_tag_ids = TagVersion.pluck(:id)
|
||||||
|
|
||||||
|
expect { run_rake_task('nico:sync') }.to change { event_rows.length }.by(1)
|
||||||
|
|
||||||
|
event = event_rows.last
|
||||||
|
expect(event).to include('event_type' => 'posts.sync', 'created_by_user_id' => nil)
|
||||||
|
created = Post.find_by!(url: 'https://www.nicovideo.jp/watch/sm10')
|
||||||
|
versions = PostVersion.where.not(id: previous_post_ids)
|
||||||
|
expect(versions.pluck(:post_id).uniq).to contain_exactly(existing.id, created.id)
|
||||||
|
expect(versions.pluck(:event_type)).to include('create', 'update')
|
||||||
|
expect(versions.pluck(:event_id).uniq).to eq([event.fetch('id')])
|
||||||
|
tag_versions = TagVersion.where.not(id: previous_tag_ids)
|
||||||
|
expect(tag_versions).not_to be_empty
|
||||||
|
expect(tag_versions.pluck(:event_id).uniq).to eq([event.fetch('id')])
|
||||||
end
|
end
|
||||||
|
|
||||||
it '既存 post の内容または tags が変わったとき update version を作る' do
|
it '既存 post の内容または tags が変わったとき update version を作る' do
|
||||||
@@ -282,6 +309,7 @@ RSpec.describe 'nico:sync' do
|
|||||||
}])
|
}])
|
||||||
|
|
||||||
allow(URI).to receive(:open).and_return(StringIO.new('<html></html>'))
|
allow(URI).to receive(:open).and_return(StringIO.new('<html></html>'))
|
||||||
|
events = event_rows
|
||||||
|
|
||||||
expect {
|
expect {
|
||||||
run_rake_task('nico:sync')
|
run_rake_task('nico:sync')
|
||||||
@@ -291,6 +319,7 @@ RSpec.describe 'nico:sync' do
|
|||||||
expect(version.version_no).to eq(1)
|
expect(version.version_no).to eq(1)
|
||||||
expect(version.event_type).to eq('create')
|
expect(version.event_type).to eq('create')
|
||||||
expect(version.tags).to eq(snapshot_tags(post.reload))
|
expect(version.tags).to eq(snapshot_tags(post.reload))
|
||||||
|
expect(event_rows).to eq(events)
|
||||||
end
|
end
|
||||||
|
|
||||||
it '新規 nico tag に nico tag version を作る' do
|
it '新規 nico tag に nico tag version を作る' do
|
||||||
|
|||||||
新しいイシューから参照
ユーザーをブロックする