From e3780e29827140c112679c21943426bcbebb9f02 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Sun, 26 Apr 2026 16:08:32 +0900 Subject: [PATCH 1/6] #317 --- backend/app/models/ip_address.rb | 3 +- .../20260426120600_create_wiki_versions.rb | 91 +++++++++++++++++++ backend/db/schema.rb | 21 ++++- 3 files changed, 113 insertions(+), 2 deletions(-) create mode 100644 backend/db/migrate/20260426120600_create_wiki_versions.rb diff --git a/backend/app/models/ip_address.rb b/backend/app/models/ip_address.rb index e5e01a3..bf73658 100644 --- a/backend/app/models/ip_address.rb +++ b/backend/app/models/ip_address.rb @@ -2,5 +2,6 @@ class IpAddress < ApplicationRecord validates :ip_address, presence: true, length: { maximum: 16 } validates :banned, inclusion: { in: [true, false] } - has_many :users + has_many :user_ips, dependent: :destroy + has_many :users, through: :user_ips end diff --git a/backend/db/migrate/20260426120600_create_wiki_versions.rb b/backend/db/migrate/20260426120600_create_wiki_versions.rb new file mode 100644 index 0000000..2584962 --- /dev/null +++ b/backend/db/migrate/20260426120600_create_wiki_versions.rb @@ -0,0 +1,91 @@ +class CreateWikiVersions < ActiveRecord::Migration[8.0] + class WikiPage < ActiveRecord::Base + self.table_name = 'wiki_pages' + end + + class WikiRevision < ActiveRecord::Base + self.table_name = 'wiki_revisions' + end + + class WikiRevisionLine < ActiveRecord::Base + self.table_name = 'wiki_revision_lines' + end + + class WikiLine < ActiveRecord::Base + self.table_name = 'wiki_lines' + end + + class WikiVersion < ActiveRecord::Base + self.table_name = 'wiki_versions' + end + + class TagName < ActiveRecord::Base + self.table_name = 'tag_names' + end + + def up + add_column :wiki_pages, :body, :text, after: :tag_name_id + + create_table :wiki_versions do |t| + t.references :wiki_page , null: false, foreign_key: true + t.integer :version_no, null: false + t.string :event_type, null: false + t.string :title, null: false + t.text :body, null: false + t.text :reason + t.datetime :created_at, null: false + t.references :created_by_user, foreign_key: { to_table: :users } + + t.index [:wiki_page_id, :version_no], unique: true + t.check_constraint 'version_no > 0', + name: 'wiki_versions_version_no_positive' + t.check_constraint "event_type IN ('create', 'update', 'discard', 'restore')", + name: 'wiki_versions_event_type_valid' + end + + WikiPage.reset_column_information + WikiVersion.reset_column_information + + say_with_time 'Backfilling wiki_versions' do + WikiPage.find_each do |page| + base_revision_id = nil + version_no = 1 + title = TagName.find(page.tag_name_id).name + body = nil + loop do + rev = WikiRevision.where(wiki_page_id: page.id).find_by(base_revision_id:) + break unless rev + + body = WikiRevisionLine.where(wiki_revision_id: rev.id).order(:position).map { |wrl| + WikiLine.find(wrl.wiki_line_id).body + }.join("\n") + + WikiVersion.create!( + wiki_page_id: page.id, + version_no:, + event_type: version_no == 1 ? 'create' : 'update', + title:, + body:, + reason: rev.message, + created_at: rev.created_at, + created_by_user_id: rev.created_user_id) + + version_no += 1 + base_revision_id = rev.id + end + if body + page.update!(body:) + else + page.destroy! + end + end + end + + change_column_null :wiki_pages, :body, false + end + + def down + drop_table :wiki_versions + remove_column :wiki_pages, :body + end +end diff --git a/backend/db/schema.rb b/backend/db/schema.rb index ede919d..6e1a67f 100644 --- a/backend/db/schema.rb +++ b/backend/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2026_04_19_035400) do +ActiveRecord::Schema[8.0].define(version: 2026_04_26_120600) do create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.string "name", null: false t.string "record_type", null: false @@ -354,6 +354,7 @@ ActiveRecord::Schema[8.0].define(version: 2026_04_19_035400) do create_table "wiki_pages", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.bigint "tag_name_id", null: false + t.text "body", null: false t.bigint "created_user_id", null: false t.bigint "updated_user_id", null: false t.datetime "created_at", null: false @@ -396,6 +397,22 @@ ActiveRecord::Schema[8.0].define(version: 2026_04_19_035400) do t.index ["wiki_page_id"], name: "index_wiki_revisions_on_wiki_page_id" end + create_table "wiki_versions", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.bigint "wiki_page_id", null: false + t.integer "version_no", null: false + t.string "event_type", null: false + t.string "title", null: false + t.text "body", null: false + t.text "reason" + t.datetime "created_at", null: false + t.bigint "created_by_user_id" + t.index ["created_by_user_id"], name: "index_wiki_versions_on_created_by_user_id" + t.index ["wiki_page_id", "version_no"], name: "index_wiki_versions_on_wiki_page_id_and_version_no", unique: true + t.index ["wiki_page_id"], name: "index_wiki_versions_on_wiki_page_id" + t.check_constraint "`event_type` in (_utf8mb4'create',_utf8mb4'update',_utf8mb4'discard',_utf8mb4'restore')", name: "wiki_versions_event_type_valid" + t.check_constraint "`version_no` > 0", name: "wiki_versions_version_no_positive" + end + add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id" add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id" add_foreign_key "material_versions", "materials" @@ -453,4 +470,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_04_19_035400) do add_foreign_key "wiki_revisions", "wiki_pages" add_foreign_key "wiki_revisions", "wiki_pages", column: "redirect_page_id" add_foreign_key "wiki_revisions", "wiki_revisions", column: "base_revision_id" + add_foreign_key "wiki_versions", "users", column: "created_by_user_id" + add_foreign_key "wiki_versions", "wiki_pages" end -- 2.34.1 From c4f5df8b44310a94f1114dca6fc889eb98ef2833 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Sun, 26 Apr 2026 17:33:26 +0900 Subject: [PATCH 2/6] #317 --- backend/app/controllers/tags_controller.rb | 5 ++ backend/app/models/wiki_page.rb | 2 + backend/app/models/wiki_version.rb | 8 ++++ backend/app/services/wiki/commit.rb | 48 +++++++------------ backend/app/services/wiki_version_recorder.rb | 17 +++++++ .../20260426120600_create_wiki_versions.rb | 2 +- 6 files changed, 51 insertions(+), 31 deletions(-) create mode 100644 backend/app/models/wiki_version.rb create mode 100644 backend/app/services/wiki_version_recorder.rb diff --git a/backend/app/controllers/tags_controller.rb b/backend/app/controllers/tags_controller.rb index 3785a9e..7fdcc22 100644 --- a/backend/app/controllers/tags_controller.rb +++ b/backend/app/controllers/tags_controller.rb @@ -302,6 +302,11 @@ class TagsController < ApplicationController NicoTagVersionRecorder.record!(tag:, event_type:, created_by_user:) else TagVersionRecorder.record!(tag:, event_type:, created_by_user:) + if tag.has_wiki + WikiVersionRecorder.record!(page: tag.tag_name.wiki_page, + event_type: :update, + created_by_user:) + end end end diff --git a/backend/app/models/wiki_page.rb b/backend/app/models/wiki_page.rb index 8d3feec..83a7f1b 100644 --- a/backend/app/models/wiki_page.rb +++ b/backend/app/models/wiki_page.rb @@ -13,6 +13,8 @@ class WikiPage < ApplicationRecord foreign_key: :redirect_page_id, dependent: :nullify + has_many :wiki_versions + belongs_to :tag_name validates :tag_name, presence: true diff --git a/backend/app/models/wiki_version.rb b/backend/app/models/wiki_version.rb new file mode 100644 index 0000000..4f55804 --- /dev/null +++ b/backend/app/models/wiki_version.rb @@ -0,0 +1,8 @@ +class WikiVersion < ApplicationRecord + include VersionRecord + + belongs_to :wiki_page + + validates :title, presence: true + validates :body, presence: true +end diff --git a/backend/app/services/wiki/commit.rb b/backend/app/services/wiki/commit.rb index c0be98a..f3e325d 100644 --- a/backend/app/services/wiki/commit.rb +++ b/backend/app/services/wiki/commit.rb @@ -21,7 +21,7 @@ module Wiki end def content! body:, message:, base_revision_id: - normalised = normalise_body(body) + normalised = normalise_body(body).gsub(/\n+$/, '') lines = split_lines(normalised) line_shas = lines.map { |line| Digest::SHA256.hexdigest(line) } @@ -37,10 +37,22 @@ module Wiki current_id = @page.wiki_revisions.maximum(:id) if current_id && current_id != base_revision_id.to_i raise Conflict, - "競合が発生してゐます(現在の Id.:#{ current_id },ベース Id.:#{ base_revision_id })." + "競合が発生してゐます" + + "(現在の Id.:#{ current_id },ベース Id.:#{ base_revision_id })." end end + @page.update!(body: normalised) + + WikiVersionRecorder.record!( + page: { title: @page.title, body: normalised }, + event_type: @page.wiki_versions.exists? ? :update : :create, + created_by_user: @created_user) + tag = @page.tag_name.tag + if tag + TagVersionRecorder.record!(tag:, event_type: :update, created_by_user: @created_user) + end + rev = WikiRevision.create!( wiki_page: @page, base_revision_id:, @@ -54,47 +66,23 @@ module Wiki rows = line_ids.each_with_index.map do |line_id, pos| { wiki_revision_id: rev.id, wiki_line_id: line_id, position: pos } end - WikiRevisionLine.insert_all!(rows) + WikiRevisionLine.insert_all!(rows) if rows.any? rev end end - def redirect! redirect_page:, message:, base_revision_id: - ActiveRecord::Base.transaction do - @page.lock! - - if base_revision_id.present? - current_id = @page.wiki_revisions.maximum(:id) - if current_id && current_id != base_revision_id.to_i - raise Conflict, - "競合が発生してゐます(現在の Id.:#{ current_id },ベース Id.:#{ base_revision_id })." - end - end - - WikiRevision.create!( - wiki_page: @page, - base_revision_id:, - created_user: @created_user, - kind: :redirect, - redirect_page:, - message:, - lines_count: 0, - tree_sha256: nil) - end - end + def redirect!(redirect_page:, message:, base_revision_id:) = raise '廃止しました.' private def normalise_body body s = body.to_s - s.gsub!("\r\n", "\n") + s.gsub!(/\r\n?/, "\n") s.encode('UTF-8', invalid: :replace, undef: :replace, replace: '🖕') end - def split_lines body - body.split("\n") - end + def split_lines(body) = body.split("\n") def upsert_lines! lines, line_shas now = Time.current diff --git a/backend/app/services/wiki_version_recorder.rb b/backend/app/services/wiki_version_recorder.rb new file mode 100644 index 0000000..a9bebe4 --- /dev/null +++ b/backend/app/services/wiki_version_recorder.rb @@ -0,0 +1,17 @@ +class WikiVersionRecorder < VersionRecorder + def self.record! page:, event_type:, created_by_user: + new(page:, event_type:, created_by_user:).record! + end + + def initialize page:, event_type:, created_by_user: + super(record: page, event_type:, created_by_user:) + end + + private + + def version_class = WikiVersion + def version_association = :wiki_versions + def record_key = :wiki_page + + def snapshot_attributes = { title: @record.title, body: @record.body } +end diff --git a/backend/db/migrate/20260426120600_create_wiki_versions.rb b/backend/db/migrate/20260426120600_create_wiki_versions.rb index 2584962..756597f 100644 --- a/backend/db/migrate/20260426120600_create_wiki_versions.rb +++ b/backend/db/migrate/20260426120600_create_wiki_versions.rb @@ -27,7 +27,7 @@ class CreateWikiVersions < ActiveRecord::Migration[8.0] add_column :wiki_pages, :body, :text, after: :tag_name_id create_table :wiki_versions do |t| - t.references :wiki_page , null: false, foreign_key: true + t.references :wiki_page, null: false, foreign_key: true t.integer :version_no, null: false t.string :event_type, null: false t.string :title, null: false -- 2.34.1 From 6ac044278f630987babe6e0139f7baa5fe4a0497 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Sun, 26 Apr 2026 18:52:31 +0900 Subject: [PATCH 3/6] #317 --- backend/app/controllers/tags_controller.rb | 7 +- backend/app/models/wiki_page.rb | 6 +- backend/app/services/wiki/commit.rb | 44 ++++- backend/app/services/wiki_version_recorder.rb | 10 +- backend/spec/factories/wiki_pages.rb | 2 + backend/spec/models/tag_spec.rb | 12 +- backend/spec/requests/tag_versions_spec.rb | 29 +++ backend/spec/requests/tags_spec.rb | 99 ++++++++++ backend/spec/requests/wiki_spec.rb | 171 ++++++++++++++---- backend/spec/services/wiki/commit_spec.rb | 120 ++++++++++++ 10 files changed, 441 insertions(+), 59 deletions(-) create mode 100644 backend/spec/services/wiki/commit_spec.rb diff --git a/backend/app/controllers/tags_controller.rb b/backend/app/controllers/tags_controller.rb index 7fdcc22..8613bd0 100644 --- a/backend/app/controllers/tags_controller.rb +++ b/backend/app/controllers/tags_controller.rb @@ -302,10 +302,9 @@ class TagsController < ApplicationController NicoTagVersionRecorder.record!(tag:, event_type:, created_by_user:) else TagVersionRecorder.record!(tag:, event_type:, created_by_user:) - if tag.has_wiki - WikiVersionRecorder.record!(page: tag.tag_name.wiki_page, - event_type: :update, - created_by_user:) + wiki_page = tag.tag_name.wiki_page + if wiki_page&.wiki_versions&.exists? + WikiVersionRecorder.record!(page: wiki_page, event_type: :update, created_by_user:) end end end diff --git a/backend/app/models/wiki_page.rb b/backend/app/models/wiki_page.rb index 83a7f1b..b725706 100644 --- a/backend/app/models/wiki_page.rb +++ b/backend/app/models/wiki_page.rb @@ -17,6 +17,7 @@ class WikiPage < ApplicationRecord belongs_to :tag_name validates :tag_name, presence: true + validates :body, presence: true def title = tag_name.name @@ -26,11 +27,6 @@ class WikiPage < ApplicationRecord def current_revision = wiki_revisions.order(id: :desc).first - def body - rev = current_revision - rev.body if rev&.content? - end - def resolve_redirect limit: 10 page = self visited = Set.new diff --git a/backend/app/services/wiki/commit.rb b/backend/app/services/wiki/commit.rb index f3e325d..4f22fc3 100644 --- a/backend/app/services/wiki/commit.rb +++ b/backend/app/services/wiki/commit.rb @@ -7,6 +7,31 @@ module Wiki ; end + def self.create_content! tag_name:, body:, created_by_user:, message: nil + normalised = normalise_body(body) + + page = WikiPage.new(tag_name:, + body: normalised, + created_user: created_by_user, + updated_user: created_by_user) + + if normalised.blank? + page.errors.add(:body, :blank) + raise ActiveRecord::RecordInvalid, page + end + + ActiveRecord::Base.transaction do + page.save! + + new(page:, created_user: created_by_user).content!( + body: normalised, + message:, + base_revision_id: nil) + + page + end + end + def self.content! page:, body:, created_user:, message: nil, base_revision_id: nil new(page:, created_user:).content!(body:, message:, base_revision_id:) end @@ -21,7 +46,12 @@ module Wiki end def content! body:, message:, base_revision_id: - normalised = normalise_body(body).gsub(/\n+$/, '') + normalised = self.class.normalise_body(body) + if normalised.blank? + @page.errors.add(:body, :blank) + raise ActiveRecord::RecordInvalid, @page + end + lines = split_lines(normalised) line_shas = lines.map { |line| Digest::SHA256.hexdigest(line) } @@ -45,11 +75,12 @@ module Wiki @page.update!(body: normalised) WikiVersionRecorder.record!( - page: { title: @page.title, body: normalised }, + page: @page, event_type: @page.wiki_versions.exists? ? :update : :create, + reason: message, created_by_user: @created_user) tag = @page.tag_name.tag - if tag + if tag&.tag_versions&.exists? TagVersionRecorder.record!(tag:, event_type: :update, created_by_user: @created_user) end @@ -74,14 +105,15 @@ module Wiki def redirect!(redirect_page:, message:, base_revision_id:) = raise '廃止しました.' - private - - def normalise_body body + def self.normalise_body body s = body.to_s s.gsub!(/\r\n?/, "\n") s.encode('UTF-8', invalid: :replace, undef: :replace, replace: '🖕') + s.gsub(/\n+$/, '') end + private + def split_lines(body) = body.split("\n") def upsert_lines! lines, line_shas diff --git a/backend/app/services/wiki_version_recorder.rb b/backend/app/services/wiki_version_recorder.rb index a9bebe4..f6aa09e 100644 --- a/backend/app/services/wiki_version_recorder.rb +++ b/backend/app/services/wiki_version_recorder.rb @@ -1,9 +1,10 @@ class WikiVersionRecorder < VersionRecorder - def self.record! page:, event_type:, created_by_user: + def self.record! page:, event_type:, reason: nil, created_by_user: new(page:, event_type:, created_by_user:).record! end - def initialize page:, event_type:, created_by_user: + def initialize page:, event_type:, reason: nil, created_by_user: + @reason = reason super(record: page, event_type:, created_by_user:) end @@ -13,5 +14,8 @@ class WikiVersionRecorder < VersionRecorder def version_association = :wiki_versions def record_key = :wiki_page - def snapshot_attributes = { title: @record.title, body: @record.body } + def snapshot_attributes = { + title: @record.title, + body: @record.body, + reason: @reason } end diff --git a/backend/spec/factories/wiki_pages.rb b/backend/spec/factories/wiki_pages.rb index b4f1496..7f5f41a 100644 --- a/backend/spec/factories/wiki_pages.rb +++ b/backend/spec/factories/wiki_pages.rb @@ -3,5 +3,7 @@ FactoryBot.define do title { "TestPage" } association :created_user, factory: :user association :updated_user, factory: :user + + body { ' ' } end end diff --git a/backend/spec/models/tag_spec.rb b/backend/spec/models/tag_spec.rb index 1dee732..1a1714b 100644 --- a/backend/spec/models/tag_spec.rb +++ b/backend/spec/models/tag_spec.rb @@ -107,11 +107,13 @@ RSpec.describe Tag, type: :model do context 'when the source tag_name has a wiki_page' do let!(:source_post_tag) { PostTag.create!(post: post_record, tag: source_tag) } let!(:wiki_page) do - WikiPage.create!( - tag_name: source_tag_name, - created_user: create_admin_user!, - updated_user: create_admin_user! - ) + admin = create_admin_user! + + Wiki::Commit.create_content!( + tag_name: source_tag_name, + body: 'source wiki body', + created_by_user: admin, + message: 'init') end it 'rolls back the transaction' do diff --git a/backend/spec/requests/tag_versions_spec.rb b/backend/spec/requests/tag_versions_spec.rb index f1d92e7..0175704 100644 --- a/backend/spec/requests/tag_versions_spec.rb +++ b/backend/spec/requests/tag_versions_spec.rb @@ -214,5 +214,34 @@ RSpec.describe 'TagVersions API', type: :request do expect(versions.size).to eq(1) expect(versions.first['version_no']).to eq(2) end + + it 'includes tag versions created by wiki updates' do + wiki_tag_name = TagName.create!(name: 'tag_versions_from_wiki') + wiki_tag = Tag.create!(tag_name: wiki_tag_name, category: :general) + + wiki_page = + Wiki::Commit.create_content!( + tag_name: wiki_tag_name, + body: 'before', + created_by_user: member, + message: 'init') + + Wiki::Commit.content!( + page: wiki_page, + body: 'after', + created_user: member, + message: 'edit', + base_revision_id: wiki_page.current_revision.id) + + get '/tags/versions', params: { id: wiki_tag.id } + + expect(response).to have_http_status(:ok) + + versions = json.fetch('versions') + + expect(json.fetch('count')).to eq(2) + expect(versions.map { |v| v['event_type'] }).to eq(['update', 'create']) + expect(versions.map { |v| v['name']['current'] }.uniq).to eq(['tag_versions_from_wiki']) + end end end diff --git a/backend/spec/requests/tags_spec.rb b/backend/spec/requests/tags_spec.rb index d3bc594..e186582 100644 --- a/backend/spec/requests/tags_spec.rb +++ b/backend/spec/requests/tags_spec.rb @@ -876,6 +876,105 @@ RSpec.describe 'Tags API', type: :request do expect(system_tag.reload.name).to eq(old_name) expect(system_tag.category).to eq(old_category) end + + it 'wiki を持つ tag を更新すると wiki version も作成する' do + wiki_page = + Wiki::Commit.create_content!( + tag_name: tag.tag_name, + body: 'wiki body before', + created_by_user: member_user, + message: 'init') + + Wiki::Commit.content!( + page: wiki_page, + body: 'wiki body before', + created_user: member_user, + message: 'init' + ) + + expect { + put "/tags/#{ tag.id }", params: { + name: 'put_wiki_version_tag', + category: 'meme', + aliases: 'unko', + parent_tags: '', + } + } + .to change(TagVersion, :count).by(2) + .and change(WikiVersion, :count).by(1) + + expect(response).to have_http_status(:ok) + + version = wiki_page.reload.wiki_versions.order(:version_no).last + + expect(version).to have_attributes( + event_type: 'update', + title: 'put_wiki_version_tag', + body: 'wiki body before', + created_by_user_id: member_user.id + ) + end + + it '別名を他 tag から奪った場合、奪はれた側の tag version も作成する' do + pending '#329 で対応予定' + + old_owner = Tag.create!( + tag_name: TagName.create!(name: 'put_alias_old_owner'), + category: :general + ) + stolen_alias = TagName.create!( + name: 'put_stolen_alias', + canonical: old_owner.tag_name + ) + + expect(old_owner.tag_name.aliases.map(&:name)).to include('put_stolen_alias') + + expect { + put "/tags/#{ tag.id }", params: { + name: 'spec_tag', + category: 'general', + aliases: 'unko put_stolen_alias', + parent_tags: '', + } + } + .to change { tag.reload.tag_versions.count }.by(2) + .and change { old_owner.reload.tag_versions.count }.by(2) + + expect(response).to have_http_status(:ok) + + expect(stolen_alias.reload.canonical).to eq(tag.tag_name) + expect(old_owner.reload.tag_name.aliases.map(&:name)).not_to include('put_stolen_alias') + + old_owner_versions = old_owner.tag_versions.order(:version_no) + + expect(old_owner_versions.first.event_type).to eq('create') + expect(old_owner_versions.first.aliases.split).to include('put_stolen_alias') + + expect(old_owner_versions.second.event_type).to eq('update') + expect(old_owner_versions.second.aliases.split).not_to include('put_stolen_alias') + end + + it 'parent_tags に指定すると循環する tag は 422 にする' do + pending '#332 で対応予定' + + child = Tag.create!( + tag_name: TagName.create!(name: 'put_cycle_child'), + category: :general + ) + + TagImplication.create!(tag: child, parent_tag: tag) + + put "/tags/#{ tag.id }", params: { + name: 'spec_tag', + category: 'general', + aliases: 'unko', + parent_tags: child.name, + } + + expect(response).to have_http_status(:unprocessable_entity) + + expect(TagImplication.where(tag:, parent_tag: child)).not_to exist + end end end end diff --git a/backend/spec/requests/wiki_spec.rb b/backend/spec/requests/wiki_spec.rb index 4541501..020e412 100644 --- a/backend/spec/requests/wiki_spec.rb +++ b/backend/spec/requests/wiki_spec.rb @@ -8,7 +8,12 @@ RSpec.describe 'Wiki API', type: :request do let!(:tn) { TagName.create!(name: 'spec_wiki_title') } let!(:page) do - WikiPage.create!(tag_name: tn, created_user: user, updated_user: user).tap do |p| + WikiPage.create!( + tag_name: tn, + body: 'init', + created_user: user, + updated_user: user + ).tap do |p| Wiki::Commit.content!(page: p, body: 'init', created_user: user, message: 'init') end end @@ -99,6 +104,17 @@ RSpec.describe 'Wiki API', type: :request do end .to change(WikiPage, :count).by(1) .and change(WikiRevision, :count).by(1) + .and change(WikiVersion, :count).by(1) + + version = page.wiki_versions.order(:version_no).last + + expect(version).to have_attributes( + version_no: 1, + event_type: 'create', + title: 'TestPage', + body: "a\nb\nc", + created_by_user_id: member.id + ) expect(response).to have_http_status(:created) @@ -135,6 +151,41 @@ RSpec.describe 'Wiki API', type: :request do # "a" の WikiLine が増殖しない(1行のはず) expect(WikiLine.where(body: 'a').count).to eq(1) end + + it 'deduplicates duplicated new lines before upsert' do + duplicated = 'duplicated_line_for_wiki_line_upsert_spec' + + post endpoint, + params: { title: 'DuplicateNewLine', body: "#{ duplicated }\n#{ duplicated }" }, + headers: auth_headers(member) + + expect(response).to have_http_status(:created) + + page = WikiPage.find(json.fetch('id')) + rev = page.current_revision + + expect(rev.lines_count).to eq(2) + expect(WikiLine.where(body: duplicated).count).to eq(1) + expect(rev.wiki_lines.where(body: duplicated).count).to eq(1) + expect(rev.wiki_revision_lines.count).to eq(2) + end + + it 'normalises CRLF and strips trailing newlines' do + post endpoint, + params: { title: 'NormalisedBody', body: "a\r\nb\r\n\r\n", message: 'normalise' }, + headers: auth_headers(member) + + expect(response).to have_http_status(:created) + + page = WikiPage.find(json.fetch('id')) + rev = page.current_revision + version = page.wiki_versions.order(:version_no).last + + expect(page.body).to eq("a\nb") + expect(version.body).to eq("a\nb") + expect(rev.lines_count).to eq(2) + expect(rev.wiki_lines.order('wiki_revision_lines.position').map(&:body)).to eq(['a', 'b']) + end end end @@ -146,17 +197,14 @@ RSpec.describe 'Wiki API', type: :request do { 'X-Transfer-Code' => user.inheritance_code } end - #let!(:page) { create(:wiki_page, title: 'TestPage') } - let!(:page) do - build(:wiki_page, title: 'TestPage').tap do |p| - puts p.errors.full_messages unless p.valid? - p.save! - end - end + let!(:test_tag_name) { TagName.create!(name: 'TestPage') } - before do - # 初期版を 1 つ作っておく(更新が“2版目”になるように) - Wiki::Commit.content!(page: page, body: "a\nb", created_user: member, message: 'init') + let!(:page) do + Wiki::Commit.create_content!( + tag_name: test_tag_name, + body: "a\nb", + created_by_user: member, + message: 'init') end context 'when not logged in' do @@ -200,7 +248,18 @@ RSpec.describe 'Wiki API', type: :request do put "/wiki/#{page.id}", params: { title: 'TestPage', body: "x\ny", message: 'edit', base_revision_id: current_id }, headers: auth_headers(member) - end.to change(WikiRevision, :count).by(1) + end + .to change(WikiRevision, :count).by(1) + .and change(WikiVersion, :count).by(1) + + version = page.wiki_versions.order(:version_no).last + + expect(version).to have_attributes( + event_type: 'update', + title: 'TestPage', + body: "x\ny", + created_by_user_id: member.id + ) expect(response).to have_http_status(:ok) @@ -211,6 +270,48 @@ RSpec.describe 'Wiki API', type: :request do expect(page.body).to eq("x\ny") expect(rev.base_revision_id).to eq(current_id) end + + it 'wiki page に対応する tag があれば tag version も作成する' do + linked_tag_name = TagName.create!(name: 'wiki_linked_tag_for_version') + linked_tag = Tag.create!(tag_name: linked_tag_name, category: :general) + linked_page = WikiPage.create!( + tag_name: linked_tag_name, + body: 'before', + created_user: member, + updated_user: member + ) + + Wiki::Commit.content!( + page: linked_page, + body: 'before', + created_user: member, + message: 'init' + ) + + current_id = linked_page.wiki_revisions.maximum(:id) + + expect { + put "/wiki/#{ linked_page.id }", + params: { + title: 'wiki_linked_tag_for_version', + body: 'after', + message: 'edit', + base_revision_id: current_id, + }, + headers: auth_headers(member) + } + .to change(WikiRevision, :count).by(1) + .and change(WikiVersion, :count).by(1) + .and change { linked_tag.reload.tag_versions.count }.by(1) + + expect(response).to have_http_status(:ok) + + version = linked_tag.reload.tag_versions.order(:version_no).last + + expect(version.event_type).to eq('update') + expect(version.name).to eq('wiki_linked_tag_for_version') + expect(version.created_by_user_id).to eq(member.id) + end end # TODO: コンフリクト未実装のため,実装したらコメント外す. @@ -263,11 +364,11 @@ RSpec.describe 'Wiki API', type: :request do before do # 追加で検索ヒット用 TagName.create!(name: 'spec_wiki_title_2') - WikiPage.create!(tag_name: TagName.find_by!(name: 'spec_wiki_title_2'), + WikiPage.create!(tag_name: TagName.find_by!(name: 'spec_wiki_title_2'), body: 'init', created_user: user, updated_user: user) TagName.create!(name: 'unrelated_title') - WikiPage.create!(tag_name: TagName.find_by!(name: 'unrelated_title'), + WikiPage.create!(tag_name: TagName.find_by!(name: 'unrelated_title'), body: 'init', created_user: user, updated_user: user) end @@ -329,7 +430,7 @@ RSpec.describe 'Wiki API', type: :request do it 'returns empty array when page has no revisions and filtered by id' do # 別ページを作って revision 無し tn2 = TagName.create!(name: 'spec_no_rev') - p2 = WikiPage.create!(tag_name: tn2, created_user: user, updated_user: user) + p2 = WikiPage.create!(tag_name: tn2, body: 'init', created_user: user, updated_user: user) get "/wiki/changes?id=#{p2.id}" expect(response).to have_http_status(:ok) @@ -398,29 +499,27 @@ RSpec.describe 'Wiki API', type: :request do expect(json['older_revision_id']).to eq(rev_a.id) expect(json['newer_revision_id']).to eq(page.current_revision.id) end + end - it 'returns 422 when "to" is redirect revision' do - # redirect revision を作る - tn2 = TagName.create!(name: 'redirect_target') - target = WikiPage.create!(tag_name: tn2, created_user: user, updated_user: user) - - Wiki::Commit.redirect!(page: page, redirect_page: target, created_user: user, message: 'redir') - redirect_rev = page.current_revision - expect(redirect_rev).to be_redirect - - get "/wiki/#{page.id}/diff?from=#{rev_a.id}&to=#{redirect_rev.id}" - expect(response).to have_http_status(:unprocessable_entity) - end - - it 'returns 422 when "from" is redirect revision' do - tn2 = TagName.create!(name: 'redirect_target2') - target = WikiPage.create!(tag_name: tn2, created_user: user, updated_user: user) - - Wiki::Commit.redirect!(page: page, redirect_page: target, created_user: user, message: 'redir2') - redirect_rev = page.current_revision + describe 'Wiki::Commit.redirect!' do + it 'raises because redirect revisions are deprecated' do + target_tag_name = TagName.create!(name: 'redirect_deprecated_target') + target = WikiPage.create!( + tag_name: target_tag_name, + body: 'target', + created_user: user, + updated_user: user + ) - get "/wiki/#{page.id}/diff?from=#{redirect_rev.id}&to=#{rev_b.id}" - expect(response).to have_http_status(:unprocessable_entity) + expect { + Wiki::Commit.redirect!( + page: page, + redirect_page: target, + created_user: user, + message: 'redirect', + base_revision_id: page.current_revision.id + ) + }.to raise_error(RuntimeError, '廃止しました.') end end end diff --git a/backend/spec/services/wiki/commit_spec.rb b/backend/spec/services/wiki/commit_spec.rb new file mode 100644 index 0000000..d8513c5 --- /dev/null +++ b/backend/spec/services/wiki/commit_spec.rb @@ -0,0 +1,120 @@ +require 'rails_helper' + +RSpec.describe Wiki::Commit do + let(:user) { create_member_user! } + + def create_page(title: 'commit_spec_page', body: 'initial body') + tag_name = TagName.create!(name: title) + + Wiki::Commit.create_content!( + tag_name:, + body:, + created_by_user: user, + message: 'init') + end + + describe '.content!' do + it 'stores normalised body in wiki_pages and wiki_versions' do + page = create_page(title: 'commit_normalised_page') + + described_class.content!( + page:, + body: "a\r\nb\r\n\r\n", + created_user: user, + message: 'init' + ) + + page.reload + version = page.wiki_versions.order(:version_no).last + + expect(page.body).to eq("a\nb") + expect(version.body).to eq("a\nb") + expect(page.current_revision.lines_count).to eq(2) + end + + it 'deduplicates duplicated missing wiki lines before upsert' do + page = create_page(title: 'commit_duplicate_line_page') + duplicated = 'commit_duplicate_line' + + described_class.content!( + page:, + body: "#{ duplicated }\n#{ duplicated }", + created_user: user, + message: 'init' + ) + + page.reload + + expect(WikiLine.where(body: duplicated).count).to eq(1) + expect(page.current_revision.lines_count).to eq(2) + expect(page.current_revision.wiki_revision_lines.count).to eq(2) + end + + it 'raises conflict when base_revision_id is stale' do + page = create_page(title: 'commit_conflict_page') + + first = described_class.content!( + page:, + body: 'first', + created_user: user, + message: 'first' + ) + + described_class.content!( + page:, + body: 'second', + created_user: user, + message: 'second', + base_revision_id: first.id + ) + + expect { + described_class.content!( + page:, + body: 'third', + created_user: user, + message: 'third', + base_revision_id: first.id + ) + }.to raise_error(Wiki::Commit::Conflict) + end + + it 'records tag version when page has corresponding tag' do + tag_name = TagName.create!(name: 'commit_linked_tag') + tag = Tag.create!(tag_name:, category: :general) + page = WikiPage.create!( + tag_name:, + body: '', + created_user: user, + updated_user: user + ) + + expect { + described_class.content!( + page:, + body: 'body', + created_user: user, + message: 'init' + ) + } + .to change(WikiVersion, :count).by(1) + .and change { tag.reload.tag_versions.count }.by(1) + end + end + + describe '.redirect!' do + it 'raises because redirect revisions are deprecated' do + page = create_page(title: 'commit_redirect_source') + target = create_page(title: 'commit_redirect_target') + + expect { + described_class.redirect!( + page:, + redirect_page: target, + created_user: user, + message: 'redirect' + ) + }.to raise_error(RuntimeError, '廃止しました.') + end + end +end -- 2.34.1 From 5f0c1953ceb44164ab40afb0dd64edcccc09864a Mon Sep 17 00:00:00 2001 From: miteruzo Date: Sun, 26 Apr 2026 20:17:05 +0900 Subject: [PATCH 4/6] #317 --- .../app/controllers/wiki_pages_controller.rb | 17 +- backend/app/services/wiki/commit.rb | 4 - backend/spec/requests/tag_versions_spec.rb | 10 +- backend/spec/requests/tags_spec.rb | 79 ++++++ backend/spec/requests/wiki_spec.rb | 246 +++++++++++++----- backend/spec/services/wiki/commit_spec.rb | 62 +++-- 6 files changed, 319 insertions(+), 99 deletions(-) diff --git a/backend/app/controllers/wiki_pages_controller.rb b/backend/app/controllers/wiki_pages_controller.rb index dc6c47f..60941b8 100644 --- a/backend/app/controllers/wiki_pages_controller.rb +++ b/backend/app/controllers/wiki_pages_controller.rb @@ -91,7 +91,7 @@ class WikiPagesController < ApplicationController return head :unprocessable_entity if name.blank? || body.blank? tag_name = TagName.find_undiscard_or_create_by!(name:) - page = WikiPage.new(tag_name:, created_user: current_user, updated_user: current_user) + page = WikiPage.new(tag_name:, body:, created_user: current_user, updated_user: current_user) if page.save message = params[:message].presence Wiki::Commit.content!(page:, body:, created_user: current_user, message:) @@ -115,10 +115,16 @@ class WikiPagesController < ApplicationController page = WikiPage.find(params[:id]) base_revision_id = page.current_revision.id - if params[:title].present? && params[:title].strip != page.title - return head :unprocessable_entity + old_title = page.title + + tag = Tag.find_by(tag_name_id: page.tag_name_id) + + if tag && title != old_title + TagVersioning.ensure_snapshot!(tag, created_by_user: current_user) end + page.tag_name.update!(name: title) if title != old_title + message = params[:message].presence Wiki::Commit.content!(page:, body:, @@ -126,6 +132,11 @@ class WikiPagesController < ApplicationController message:, base_revision_id:) + if tag && title != old_title + tag.reload + TagVersionRecorder.record!(tag:, event_type: :update, created_by_user: current_user) + end + head :ok end diff --git a/backend/app/services/wiki/commit.rb b/backend/app/services/wiki/commit.rb index 4f22fc3..dde51fd 100644 --- a/backend/app/services/wiki/commit.rb +++ b/backend/app/services/wiki/commit.rb @@ -79,10 +79,6 @@ module Wiki event_type: @page.wiki_versions.exists? ? :update : :create, reason: message, created_by_user: @created_user) - tag = @page.tag_name.tag - if tag&.tag_versions&.exists? - TagVersionRecorder.record!(tag:, event_type: :update, created_by_user: @created_user) - end rev = WikiRevision.create!( wiki_page: @page, diff --git a/backend/spec/requests/tag_versions_spec.rb b/backend/spec/requests/tag_versions_spec.rb index 0175704..aae790c 100644 --- a/backend/spec/requests/tag_versions_spec.rb +++ b/backend/spec/requests/tag_versions_spec.rb @@ -215,7 +215,7 @@ RSpec.describe 'TagVersions API', type: :request do expect(versions.first['version_no']).to eq(2) end - it 'includes tag versions created by wiki updates' do + it 'does not create tag versions by wiki updates when tag has no versions yet' do wiki_tag_name = TagName.create!(name: 'tag_versions_from_wiki') wiki_tag = Tag.create!(tag_name: wiki_tag_name, category: :general) @@ -236,12 +236,8 @@ RSpec.describe 'TagVersions API', type: :request do get '/tags/versions', params: { id: wiki_tag.id } expect(response).to have_http_status(:ok) - - versions = json.fetch('versions') - - expect(json.fetch('count')).to eq(2) - expect(versions.map { |v| v['event_type'] }).to eq(['update', 'create']) - expect(versions.map { |v| v['name']['current'] }.uniq).to eq(['tag_versions_from_wiki']) + expect(json.fetch('versions')).to eq([]) + expect(json.fetch('count')).to eq(0) end end end diff --git a/backend/spec/requests/tags_spec.rb b/backend/spec/requests/tags_spec.rb index e186582..fa442fe 100644 --- a/backend/spec/requests/tags_spec.rb +++ b/backend/spec/requests/tags_spec.rb @@ -472,6 +472,54 @@ RSpec.describe 'Tags API', type: :request do expect(nico_tag.reload.category).to eq('nico') end end + + it 'PATCH で tag の name を変更すると対応する wiki version を作成する' do + wiki_page = + Wiki::Commit.create_content!( + tag_name: tag.tag_name, + body: 'wiki body before', + created_by_user: member_user, + message: 'init') + + expect { + patch "/tags/#{ tag.id }", params: { + name: 'patch_wiki_renamed_tag', + } + } + .to change(TagVersion, :count).by(2) + .and change(WikiVersion, :count).by(1) + + expect(response).to have_http_status(:ok) + + version = wiki_page.reload.wiki_versions.order(:version_no).last + + expect(version).to have_attributes( + event_type: 'update', + title: 'patch_wiki_renamed_tag', + body: 'wiki body before', + created_by_user_id: member_user.id + ) + end + + it 'tag の category だけを変更しても wiki version は作成しない' do + wiki_page = + Wiki::Commit.create_content!( + tag_name: tag.tag_name, + body: 'wiki body before', + created_by_user: member_user, + message: 'init') + + before_count = wiki_page.reload.wiki_versions.count + + expect { + patch "/tags/#{ tag.id }", params: { + category: 'meme', + } + }.to change(TagVersion, :count).by(2) + + expect(response).to have_http_status(:ok) + expect(wiki_page.reload.wiki_versions.count).to eq(before_count) + end end describe 'GET /tags/with-depth' do @@ -976,5 +1024,36 @@ RSpec.describe 'Tags API', type: :request do expect(TagImplication.where(tag:, parent_tag: child)).not_to exist end end + + it 'tag の name を変更すると対応する wiki version を作成する' do + wiki_page = + Wiki::Commit.create_content!( + tag_name: tag.tag_name, + body: 'wiki body before', + created_by_user: member_user, + message: 'init') + + expect { + put "/tags/#{ tag.id }", params: { + name: 'put_wiki_renamed_tag', + category: 'general', + aliases: 'unko', + parent_tags: '', + } + } + .to change(TagVersion, :count).by(2) + .and change(WikiVersion, :count).by(1) + + expect(response).to have_http_status(:ok) + + version = wiki_page.reload.wiki_versions.order(:version_no).last + + expect(version).to have_attributes( + event_type: 'update', + title: 'put_wiki_renamed_tag', + body: 'wiki body before', + created_by_user_id: member_user.id + ) + end end end diff --git a/backend/spec/requests/wiki_spec.rb b/backend/spec/requests/wiki_spec.rb index 020e412..d6cf88f 100644 --- a/backend/spec/requests/wiki_spec.rb +++ b/backend/spec/requests/wiki_spec.rb @@ -4,18 +4,19 @@ require 'securerandom' RSpec.describe 'Wiki API', type: :request do + def auth_headers(user) + { 'X-Transfer-Code' => user.inheritance_code } + end + let!(:user) { create_member_user! } let!(:tn) { TagName.create!(name: 'spec_wiki_title') } let!(:page) do - WikiPage.create!( - tag_name: tn, - body: 'init', - created_user: user, - updated_user: user - ).tap do |p| - Wiki::Commit.content!(page: p, body: 'init', created_user: user, message: 'init') - end + Wiki::Commit.create_content!( + tag_name: tn, + body: 'init', + created_by_user: user, + message: 'init') end describe 'GET /wiki' do @@ -42,11 +43,12 @@ RSpec.describe 'Wiki API', type: :request do context 'when wiki page exists' do it 'returns wiki page with title' do request + expect(response).to have_http_status(:ok) expect(json).to include( - 'id' => page.id, - 'title' => 'spec_wiki_title') + 'id' => page.id, + 'title' => 'spec_wiki_title') end end @@ -55,6 +57,7 @@ RSpec.describe 'Wiki API', type: :request do it 'returns 404' do request + expect(response).to have_http_status(:not_found) end end @@ -106,7 +109,13 @@ RSpec.describe 'Wiki API', type: :request do .and change(WikiRevision, :count).by(1) .and change(WikiVersion, :count).by(1) - version = page.wiki_versions.order(:version_no).last + expect(response).to have_http_status(:created) + + page_id = json.fetch('id') + expect(json.fetch('title')).to eq('TestPage') + + created_page = WikiPage.find(page_id) + version = created_page.wiki_versions.order(:version_no).last expect(version).to have_attributes( version_no: 1, @@ -116,24 +125,16 @@ RSpec.describe 'Wiki API', type: :request do created_by_user_id: member.id ) - expect(response).to have_http_status(:created) - - page_id = json.fetch('id') - expect(json.fetch('title')).to eq('TestPage') - - page = WikiPage.find(page_id) - rev = page.current_revision + rev = created_page.current_revision expect(rev).to be_present expect(rev).to be_content expect(rev.message).to eq('init') - # body が復元できること - expect(page.body).to eq("a\nb\nc") + expect(created_page.body).to eq("a\nb\nc") - # 行数とリレーションの整合 expect(rev.lines_count).to eq(3) expect(rev.wiki_revision_lines.order(:position).pluck(:position)).to eq([0, 1, 2]) - expect(rev.wiki_lines.pluck(:body)).to match_array(%w[a b c]) + expect(rev.wiki_lines.pluck(:body)).to match_array(['a', 'b', 'c']) end it 'reuses existing WikiLine rows by sha256' do @@ -166,8 +167,8 @@ RSpec.describe 'Wiki API', type: :request do expect(rev.lines_count).to eq(2) expect(WikiLine.where(body: duplicated).count).to eq(1) - expect(rev.wiki_lines.where(body: duplicated).count).to eq(1) expect(rev.wiki_revision_lines.count).to eq(2) + expect(rev.wiki_revision_lines.pluck(:wiki_line_id).uniq.size).to eq(1) end it 'normalises CRLF and strips trailing newlines' do @@ -230,14 +231,6 @@ RSpec.describe 'Wiki API', type: :request do headers: auth_headers(member) expect(response).to have_http_status(:unprocessable_entity) end - - it 'returns 422 when title mismatched (if you forbid rename here)' do - put "/wiki/#{page.id}", - params: { title: 'OtherTitle', body: 'x' }, - headers: auth_headers(member) - # 君の controller 例だと title 変更は 422 にしてた - expect(response).to have_http_status(:unprocessable_entity) - end end context 'when success' do @@ -271,29 +264,29 @@ RSpec.describe 'Wiki API', type: :request do expect(rev.base_revision_id).to eq(current_id) end - it 'wiki page に対応する tag があれば tag version も作成する' do - linked_tag_name = TagName.create!(name: 'wiki_linked_tag_for_version') + it 'wiki body だけを変更しても tag version は作成しない' do + linked_tag_name = TagName.create!(name: 'wiki_body_only_tag') linked_tag = Tag.create!(tag_name: linked_tag_name, category: :general) - linked_page = WikiPage.create!( - tag_name: linked_tag_name, - body: 'before', - created_user: member, - updated_user: member - ) - Wiki::Commit.content!( - page: linked_page, - body: 'before', - created_user: member, - message: 'init' - ) + TagVersionRecorder.record!( + tag: linked_tag, + event_type: :create, + created_by_user: member) + + linked_page = + Wiki::Commit.create_content!( + tag_name: linked_tag_name, + body: 'before', + created_by_user: member, + message: 'init') - current_id = linked_page.wiki_revisions.maximum(:id) + current_id = linked_page.current_revision.id + before_count = linked_tag.reload.tag_versions.count expect { put "/wiki/#{ linked_page.id }", params: { - title: 'wiki_linked_tag_for_version', + title: 'wiki_body_only_tag', body: 'after', message: 'edit', base_revision_id: current_id, @@ -302,15 +295,9 @@ RSpec.describe 'Wiki API', type: :request do } .to change(WikiRevision, :count).by(1) .and change(WikiVersion, :count).by(1) - .and change { linked_tag.reload.tag_versions.count }.by(1) expect(response).to have_http_status(:ok) - - version = linked_tag.reload.tag_versions.order(:version_no).last - - expect(version.event_type).to eq('update') - expect(version.name).to eq('wiki_linked_tag_for_version') - expect(version.created_by_user_id).to eq(member.id) + expect(linked_tag.reload.tag_versions.count).to eq(before_count) end end @@ -362,14 +349,17 @@ RSpec.describe 'Wiki API', type: :request do describe 'GET /wiki/search' do before do - # 追加で検索ヒット用 - TagName.create!(name: 'spec_wiki_title_2') - WikiPage.create!(tag_name: TagName.find_by!(name: 'spec_wiki_title_2'), body: 'init', - created_user: user, updated_user: user) + Wiki::Commit.create_content!( + tag_name: TagName.create!(name: 'spec_wiki_title_2'), + body: 'search body 2', + created_by_user: user, + message: 'init') - TagName.create!(name: 'unrelated_title') - WikiPage.create!(tag_name: TagName.find_by!(name: 'unrelated_title'), body: 'init', - created_user: user, updated_user: user) + Wiki::Commit.create_content!( + tag_name: TagName.create!(name: 'unrelated_title'), + body: 'unrelated body', + created_by_user: user, + message: 'init') end it 'returns up to 20 pages filtered by title like' do @@ -379,7 +369,9 @@ RSpec.describe 'Wiki API', type: :request do expect(json).to be_an(Array) titles = json.map { |p| p['title'] } - expect(titles).to include('spec_wiki_title', 'spec_wiki_title_2') + + expect(titles).to include('spec_wiki_title') + expect(titles).to include('spec_wiki_title_2') expect(titles).not_to include('unrelated_title') end @@ -430,7 +422,12 @@ RSpec.describe 'Wiki API', type: :request do it 'returns empty array when page has no revisions and filtered by id' do # 別ページを作って revision 無し tn2 = TagName.create!(name: 'spec_no_rev') - p2 = WikiPage.create!(tag_name: tn2, body: 'init', created_user: user, updated_user: user) + # 異常データ: revision 無し WikiPage を直接作る + p2 = WikiPage.create!( + tag_name: tn2, + body: 'init', + created_user: user, + updated_user: user) get "/wiki/changes?id=#{p2.id}" expect(response).to have_http_status(:ok) @@ -504,12 +501,12 @@ RSpec.describe 'Wiki API', type: :request do describe 'Wiki::Commit.redirect!' do it 'raises because redirect revisions are deprecated' do target_tag_name = TagName.create!(name: 'redirect_deprecated_target') - target = WikiPage.create!( - tag_name: target_tag_name, - body: 'target', - created_user: user, - updated_user: user - ) + target = + Wiki::Commit.create_content!( + tag_name: target_tag_name, + body: 'target', + created_by_user: user, + message: 'init') expect { Wiki::Commit.redirect!( @@ -522,4 +519,115 @@ RSpec.describe 'Wiki API', type: :request do }.to raise_error(RuntimeError, '廃止しました.') end end + + it 'wiki title を変更すると対応する tag の version を作成する' do + linked_tag_name = TagName.create!(name: 'wiki_linked_tag_for_version') + linked_tag = Tag.create!(tag_name: linked_tag_name, category: :general) + + linked_page = + Wiki::Commit.create_content!( + tag_name: linked_tag_name, + body: 'before', + created_by_user: user, + message: 'init') + + current_id = linked_page.current_revision.id + + expect { + put "/wiki/#{ linked_page.id }", + params: { + title: 'wiki_linked_tag_for_version_renamed', + body: 'after', + message: 'edit', + base_revision_id: current_id, + }, + headers: auth_headers(member) + } + .to change(WikiRevision, :count).by(1) + .and change(WikiVersion, :count).by(1) + .and change { linked_tag.reload.tag_versions.count }.by(2) + + expect(response).to have_http_status(:ok) + + linked_tag.reload + expect(linked_tag.name).to eq('wiki_linked_tag_for_version_renamed') + + versions = linked_tag.tag_versions.order(:version_no) + + expect(versions.first.event_type).to eq('create') + expect(versions.first.name).to eq('wiki_linked_tag_for_version') + + expect(versions.second.event_type).to eq('update') + expect(versions.second.name).to eq('wiki_linked_tag_for_version_renamed') + end + + it 'wiki body だけを変更しても tag version は作成しない' do + linked_tag_name = TagName.create!(name: 'wiki_body_only_tag') + linked_tag = Tag.create!(tag_name: linked_tag_name, category: :general) + + linked_page = + Wiki::Commit.create_content!( + tag_name: linked_tag_name, + body: 'before', + created_by_user: user, + message: 'init') + + current_id = linked_page.current_revision.id + + expect { + put "/wiki/#{ linked_page.id }", + params: { + title: 'wiki_body_only_tag', + body: 'after', + message: 'edit', + base_revision_id: current_id, + }, + headers: auth_headers(member) + } + .to change(WikiRevision, :count).by(1) + .and change(WikiVersion, :count).by(1) + + expect(linked_tag.reload.tag_versions.count).to eq(0) + end + + it 'wiki title を変更すると対応する tag の version を作成する' do + linked_tag_name = TagName.create!(name: 'wiki_linked_tag_for_version') + linked_tag = Tag.create!(tag_name: linked_tag_name, category: :general) + + linked_page = + Wiki::Commit.create_content!( + tag_name: linked_tag_name, + body: 'before', + created_by_user: user, + message: 'init') + + current_id = linked_page.current_revision.id + + expect { + put "/wiki/#{ linked_page.id }", + params: { + title: 'wiki_linked_tag_for_version_renamed', + body: 'after', + message: 'rename', + base_revision_id: current_id, + }, + headers: auth_headers(user) + } + .to change(WikiRevision, :count).by(1) + .and change(WikiVersion, :count).by(1) + .and change { linked_tag.reload.tag_versions.count }.by(2) + + expect(response).to have_http_status(:ok) + + linked_tag.reload + expect(linked_tag.name).to eq('wiki_linked_tag_for_version_renamed') + + versions = linked_tag.tag_versions.order(:version_no) + + expect(versions.first.event_type).to eq('create') + expect(versions.first.name).to eq('wiki_linked_tag_for_version') + + expect(versions.second.event_type).to eq('update') + expect(versions.second.name).to eq('wiki_linked_tag_for_version_renamed') + end end diff --git a/backend/spec/services/wiki/commit_spec.rb b/backend/spec/services/wiki/commit_spec.rb index d8513c5..2e564cf 100644 --- a/backend/spec/services/wiki/commit_spec.rb +++ b/backend/spec/services/wiki/commit_spec.rb @@ -79,26 +79,56 @@ RSpec.describe Wiki::Commit do }.to raise_error(Wiki::Commit::Conflict) end - it 'records tag version when page has corresponding tag' do - tag_name = TagName.create!(name: 'commit_linked_tag') + it 'does not record tag version when corresponding tag has no versions' do + tag_name = TagName.create!(name: 'commit_linked_tag_without_versions') tag = Tag.create!(tag_name:, category: :general) - page = WikiPage.create!( - tag_name:, - body: '', - created_user: user, - updated_user: user - ) + + page = + described_class.create_content!( + tag_name:, + body: 'before', + created_by_user: user, + message: 'init') + + expect(tag.reload.tag_versions.count).to eq(0) + + current_revision_id = page.current_revision.id expect { described_class.content!( - page:, - body: 'body', - created_user: user, - message: 'init' - ) - } - .to change(WikiVersion, :count).by(1) - .and change { tag.reload.tag_versions.count }.by(1) + page:, + body: 'after', + created_user: user, + message: 'edit', + base_revision_id: current_revision_id) + }.to change(WikiVersion, :count).by(1) + + expect(tag.reload.tag_versions.count).to eq(0) + end + + it 'does not record tag version when corresponding tag has no versions' do + tag_name = TagName.create!(name: 'commit_linked_tag_without_versions') + tag = Tag.create!(tag_name:, category: :general) + + page = + described_class.create_content!( + tag_name:, + body: 'before', + created_by_user: user, + message: 'init') + + current_revision_id = page.current_revision.id + + expect { + described_class.content!( + page:, + body: 'after', + created_user: user, + message: 'edit', + base_revision_id: current_revision_id) + }.to change(WikiVersion, :count).by(1) + + expect(tag.reload.tag_versions.count).to eq(0) end end -- 2.34.1 From d2eb69d3b08ad4ebb33d47eaf60c65e223cd90e8 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Sun, 26 Apr 2026 20:53:20 +0900 Subject: [PATCH 5/6] #317 --- backend/app/controllers/tags_controller.rb | 34 +++-- .../app/controllers/wiki_pages_controller.rb | 64 +++++---- backend/app/services/wiki/commit.rb | 17 ++- backend/app/services/wiki_version_recorder.rb | 2 +- backend/spec/requests/tags_spec.rb | 128 +++++++++--------- backend/spec/requests/wiki_spec.rb | 103 +++----------- 6 files changed, 151 insertions(+), 197 deletions(-) diff --git a/backend/app/controllers/tags_controller.rb b/backend/app/controllers/tags_controller.rb index 8613bd0..c0d54d1 100644 --- a/backend/app/controllers/tags_controller.rb +++ b/backend/app/controllers/tags_controller.rb @@ -249,7 +249,10 @@ class TagsController < ApplicationController update_parent_tags!(tag, parent_names) tag.reload - record_tag_version!(tag, event_type: :update, created_by_user: current_user) + record_tag_version!(tag, + event_type: :update, + created_by_user: current_user, + name_changed: name.present? && name != old_name) end render json: TagRepr.base(tag.reload) @@ -272,10 +275,19 @@ class TagsController < ApplicationController ApplicationRecord.transaction do TagVersioning.ensure_snapshot!(tag, created_by_user: current_user) + old_name = tag.name + name_changed = name.present? && name != old_name + tag.tag_name.update!(name:) if name.present? tag.update!(category:) if category.present? - record_tag_version!(tag, event_type: :update, created_by_user: current_user) + tag.reload + + record_tag_version!( + tag, + event_type: :update, + created_by_user: current_user, + name_changed:) end render json: TagRepr.base(tag.reload) @@ -297,16 +309,20 @@ class TagsController < ApplicationController material: material.as_json&.merge(file:, content_type:)) end - def record_tag_version! tag, event_type:, created_by_user: + def record_tag_version! tag, event_type:, created_by_user:, name_changed: false if tag.nico? NicoTagVersionRecorder.record!(tag:, event_type:, created_by_user:) - else - TagVersionRecorder.record!(tag:, event_type:, created_by_user:) - wiki_page = tag.tag_name.wiki_page - if wiki_page&.wiki_versions&.exists? - WikiVersionRecorder.record!(page: wiki_page, event_type: :update, created_by_user:) - end + return end + + TagVersionRecorder.record!(tag:, event_type:, created_by_user:) + + return unless name_changed + + wiki_page = tag.tag_name.wiki_page + return unless wiki_page&.wiki_versions&.exists? + + WikiVersionRecorder.record!(page: wiki_page, event_type: :update, created_by_user:) end def update_aliases! tag, alias_names diff --git a/backend/app/controllers/wiki_pages_controller.rb b/backend/app/controllers/wiki_pages_controller.rb index 60941b8..de42c52 100644 --- a/backend/app/controllers/wiki_pages_controller.rb +++ b/backend/app/controllers/wiki_pages_controller.rb @@ -85,22 +85,24 @@ class WikiPagesController < ApplicationController return head :unauthorized unless current_user return head :forbidden unless current_user.gte_member? - name = params[:title]&.strip + title = params[:title].to_s.strip body = params[:body].to_s + message = params[:message].presence - return head :unprocessable_entity if name.blank? || body.blank? + return head :unprocessable_entity if title.blank? || body.blank? - tag_name = TagName.find_undiscard_or_create_by!(name:) - page = WikiPage.new(tag_name:, body:, created_user: current_user, updated_user: current_user) - if page.save - message = params[:message].presence - Wiki::Commit.content!(page:, body:, created_user: current_user, message:) + tag_name = TagName.find_undiscard_or_create_by!(name: title) - render json: WikiPageRepr.base(page), status: :created - else - render json: { errors: page.errors.full_messages }, - status: :unprocessable_entity - end + page = + Wiki::Commit.create_content!( + tag_name:, + body:, + created_by_user: current_user, + message:) + + render json: WikiPageRepr.base(page), status: :created + rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotUnique + head :unprocessable_entity end def update @@ -113,28 +115,32 @@ class WikiPagesController < ApplicationController return head :unprocessable_entity if title.blank? || body.blank? page = WikiPage.find(params[:id]) - base_revision_id = page.current_revision.id + base_revision_id = params[:base_revision_id].presence - old_title = page.title + ApplicationRecord.transaction do + page.lock! - tag = Tag.find_by(tag_name_id: page.tag_name_id) + old_title = page.title - if tag && title != old_title - TagVersioning.ensure_snapshot!(tag, created_by_user: current_user) - end + tag = Tag.find_by(tag_name_id: page.tag_name_id) - page.tag_name.update!(name: title) if title != old_title + if tag && title != old_title + TagVersioning.ensure_snapshot!(tag, created_by_user: current_user) + end - message = params[:message].presence - Wiki::Commit.content!(page:, - body:, - created_user: current_user, - message:, - base_revision_id:) - - if tag && title != old_title - tag.reload - TagVersionRecorder.record!(tag:, event_type: :update, created_by_user: current_user) + page.tag_name.update!(name: title) if title != old_title + + message = params[:message].presence + Wiki::Commit.content!(page:, + body:, + created_user: current_user, + message:, + base_revision_id:) + + if tag && title != old_title + tag.reload + TagVersionRecorder.record!(tag:, event_type: :update, created_by_user: current_user) + end end head :ok diff --git a/backend/app/services/wiki/commit.rb b/backend/app/services/wiki/commit.rb index dde51fd..bd6fb7b 100644 --- a/backend/app/services/wiki/commit.rb +++ b/backend/app/services/wiki/commit.rb @@ -117,18 +117,21 @@ module Wiki id_by_sha = WikiLine.where(sha256: line_shas).pluck(:sha256, :id).to_h - missing_rows = [] + missing_by_sha = { } + line_shas.each_with_index do |sha, i| next if id_by_sha.key?(sha) + next if missing_by_sha.key?(sha) - missing_rows << { sha256: sha, - body: lines[i], - created_at: now, - updated_at: now } + missing_by_sha[sha] = { + sha256: sha, + body: lines[i], + created_at: now, + updated_at: now } end - if missing_rows.any? - WikiLine.upsert_all(missing_rows) + if missing_by_sha.any? + WikiLine.upsert_all(missing_by_sha.values) id_by_sha = WikiLine.where(sha256: line_shas).pluck(:sha256, :id).to_h end diff --git a/backend/app/services/wiki_version_recorder.rb b/backend/app/services/wiki_version_recorder.rb index f6aa09e..41e1db9 100644 --- a/backend/app/services/wiki_version_recorder.rb +++ b/backend/app/services/wiki_version_recorder.rb @@ -1,6 +1,6 @@ class WikiVersionRecorder < VersionRecorder def self.record! page:, event_type:, reason: nil, created_by_user: - new(page:, event_type:, created_by_user:).record! + new(page:, event_type:, reason:, created_by_user:).record! end def initialize page:, event_type:, reason: nil, created_by_user: diff --git a/backend/spec/requests/tags_spec.rb b/backend/spec/requests/tags_spec.rb index fa442fe..864cdae 100644 --- a/backend/spec/requests/tags_spec.rb +++ b/backend/spec/requests/tags_spec.rb @@ -471,54 +471,54 @@ RSpec.describe 'Tags API', type: :request do expect(response).to have_http_status(:unprocessable_entity) expect(nico_tag.reload.category).to eq('nico') end - end - it 'PATCH で tag の name を変更すると対応する wiki version を作成する' do - wiki_page = - Wiki::Commit.create_content!( - tag_name: tag.tag_name, - body: 'wiki body before', - created_by_user: member_user, - message: 'init') - - expect { - patch "/tags/#{ tag.id }", params: { - name: 'patch_wiki_renamed_tag', + it 'PATCH で tag の name を変更すると対応する wiki version を作成する' do + wiki_page = + Wiki::Commit.create_content!( + tag_name: tag.tag_name, + body: 'wiki body before', + created_by_user: member_user, + message: 'init') + + expect { + patch "/tags/#{ tag.id }", params: { + name: 'patch_wiki_renamed_tag', + } } - } - .to change(TagVersion, :count).by(2) - .and change(WikiVersion, :count).by(1) + .to change(TagVersion, :count).by(2) + .and change(WikiVersion, :count).by(1) - expect(response).to have_http_status(:ok) + expect(response).to have_http_status(:ok) - version = wiki_page.reload.wiki_versions.order(:version_no).last + version = wiki_page.reload.wiki_versions.order(:version_no).last - expect(version).to have_attributes( - event_type: 'update', - title: 'patch_wiki_renamed_tag', - body: 'wiki body before', - created_by_user_id: member_user.id - ) - end + expect(version).to have_attributes( + event_type: 'update', + title: 'patch_wiki_renamed_tag', + body: 'wiki body before', + created_by_user_id: member_user.id + ) + end - it 'tag の category だけを変更しても wiki version は作成しない' do - wiki_page = - Wiki::Commit.create_content!( - tag_name: tag.tag_name, - body: 'wiki body before', - created_by_user: member_user, - message: 'init') + it 'tag の category だけを変更しても wiki version は作成しない' do + wiki_page = + Wiki::Commit.create_content!( + tag_name: tag.tag_name, + body: 'wiki body before', + created_by_user: member_user, + message: 'init') - before_count = wiki_page.reload.wiki_versions.count + before_wiki_version_count = wiki_page.reload.wiki_versions.count - expect { - patch "/tags/#{ tag.id }", params: { - category: 'meme', - } - }.to change(TagVersion, :count).by(2) + expect { + patch "/tags/#{ tag.id }", params: { + category: 'meme', + } + }.to change(TagVersion, :count).by(2) - expect(response).to have_http_status(:ok) - expect(wiki_page.reload.wiki_versions.count).to eq(before_count) + expect(response).to have_http_status(:ok) + expect(wiki_page.reload.wiki_versions.count).to eq(before_wiki_version_count) + end end end @@ -1023,37 +1023,37 @@ RSpec.describe 'Tags API', type: :request do expect(TagImplication.where(tag:, parent_tag: child)).not_to exist end - end - it 'tag の name を変更すると対応する wiki version を作成する' do - wiki_page = - Wiki::Commit.create_content!( - tag_name: tag.tag_name, - body: 'wiki body before', - created_by_user: member_user, - message: 'init') + it 'tag の name を変更すると対応する wiki version を作成する' do + wiki_page = + Wiki::Commit.create_content!( + tag_name: tag.tag_name, + body: 'wiki body before', + created_by_user: member_user, + message: 'init') - expect { - put "/tags/#{ tag.id }", params: { - name: 'put_wiki_renamed_tag', - category: 'general', - aliases: 'unko', - parent_tags: '', + expect { + put "/tags/#{ tag.id }", params: { + name: 'put_wiki_renamed_tag', + category: 'general', + aliases: 'unko', + parent_tags: '', + } } - } - .to change(TagVersion, :count).by(2) - .and change(WikiVersion, :count).by(1) + .to change(TagVersion, :count).by(2) + .and change(WikiVersion, :count).by(1) - expect(response).to have_http_status(:ok) + expect(response).to have_http_status(:ok) - version = wiki_page.reload.wiki_versions.order(:version_no).last + version = wiki_page.reload.wiki_versions.order(:version_no).last - expect(version).to have_attributes( - event_type: 'update', - title: 'put_wiki_renamed_tag', - body: 'wiki body before', - created_by_user_id: member_user.id - ) + expect(version).to have_attributes( + event_type: 'update', + title: 'put_wiki_renamed_tag', + body: 'wiki body before', + created_by_user_id: member_user.id + ) + end end end end diff --git a/backend/spec/requests/wiki_spec.rb b/backend/spec/requests/wiki_spec.rb index d6cf88f..9b6e8c1 100644 --- a/backend/spec/requests/wiki_spec.rb +++ b/backend/spec/requests/wiki_spec.rb @@ -301,23 +301,22 @@ RSpec.describe 'Wiki API', type: :request do end end - # TODO: コンフリクト未実装のため,実装したらコメント外す. - # context 'when conflict' do - # it 'returns 409 when base_revision_id mismatches' do - # # 先に別ユーザ(同じ member でもOK)が 1 回更新して先頭を進める - # Wiki::Commit.content!(page: page, body: "zzz", created_user: member, message: 'other edit') - # page.reload - - # stale_id = page.wiki_revisions.order(:id).first.id # わざと古い id - # put "/wiki/#{page.id}", - # params: { title: 'TestPage', body: 'x', base_revision_id: stale_id }, - # headers: auth_headers(member) - - # expect(response).to have_http_status(:conflict) - # json = JSON.parse(response.body) - # expect(json['error']).to eq('conflict') - # end - # end + context 'when conflict' do + it 'returns 409 when base_revision_id mismatches' do + # 先に別ユーザ(同じ member でもOK)が 1 回更新して先頭を進める + Wiki::Commit.content!(page: page, body: "zzz", created_user: member, message: 'other edit') + page.reload + + stale_id = page.wiki_revisions.order(:id).first.id # わざと古い id + put "/wiki/#{page.id}", + params: { title: 'TestPage', body: 'x', base_revision_id: stale_id }, + headers: auth_headers(member) + + expect(response).to have_http_status(:conflict) + json = JSON.parse(response.body) + expect(json['error']).to eq('conflict') + end + end context 'when page not found' do it 'returns 404' do @@ -520,76 +519,6 @@ RSpec.describe 'Wiki API', type: :request do end end - it 'wiki title を変更すると対応する tag の version を作成する' do - linked_tag_name = TagName.create!(name: 'wiki_linked_tag_for_version') - linked_tag = Tag.create!(tag_name: linked_tag_name, category: :general) - - linked_page = - Wiki::Commit.create_content!( - tag_name: linked_tag_name, - body: 'before', - created_by_user: user, - message: 'init') - - current_id = linked_page.current_revision.id - - expect { - put "/wiki/#{ linked_page.id }", - params: { - title: 'wiki_linked_tag_for_version_renamed', - body: 'after', - message: 'edit', - base_revision_id: current_id, - }, - headers: auth_headers(member) - } - .to change(WikiRevision, :count).by(1) - .and change(WikiVersion, :count).by(1) - .and change { linked_tag.reload.tag_versions.count }.by(2) - - expect(response).to have_http_status(:ok) - - linked_tag.reload - expect(linked_tag.name).to eq('wiki_linked_tag_for_version_renamed') - - versions = linked_tag.tag_versions.order(:version_no) - - expect(versions.first.event_type).to eq('create') - expect(versions.first.name).to eq('wiki_linked_tag_for_version') - - expect(versions.second.event_type).to eq('update') - expect(versions.second.name).to eq('wiki_linked_tag_for_version_renamed') - end - - it 'wiki body だけを変更しても tag version は作成しない' do - linked_tag_name = TagName.create!(name: 'wiki_body_only_tag') - linked_tag = Tag.create!(tag_name: linked_tag_name, category: :general) - - linked_page = - Wiki::Commit.create_content!( - tag_name: linked_tag_name, - body: 'before', - created_by_user: user, - message: 'init') - - current_id = linked_page.current_revision.id - - expect { - put "/wiki/#{ linked_page.id }", - params: { - title: 'wiki_body_only_tag', - body: 'after', - message: 'edit', - base_revision_id: current_id, - }, - headers: auth_headers(member) - } - .to change(WikiRevision, :count).by(1) - .and change(WikiVersion, :count).by(1) - - expect(linked_tag.reload.tag_versions.count).to eq(0) - end - it 'wiki title を変更すると対応する tag の version を作成する' do linked_tag_name = TagName.create!(name: 'wiki_linked_tag_for_version') linked_tag = Tag.create!(tag_name: linked_tag_name, category: :general) -- 2.34.1 From 90c1842224830ad8368e0575299cfba6abefafc7 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Sun, 26 Apr 2026 21:24:50 +0900 Subject: [PATCH 6/6] #317 --- backend/app/controllers/tags_controller.rb | 28 ++- .../tag_wiki_history_integrity_spec.rb | 160 ++++++++++++++ .../requests/wiki_body_search_pending_spec.rb | 27 +++ backend/spec/requests/wiki_conflict_spec.rb | 42 ++++ .../requests/wiki_history_integrity_spec.rb | 196 ++++++++++++++++++ .../requests/wiki_restore_pending_spec.rb | 37 ++++ .../requests/wiki_title_collision_spec.rb | 62 ++++++ .../services/wiki/commit_integrity_spec.rb | 173 ++++++++++++++++ .../services/wiki_version_recorder_spec.rb | 99 +++++++++ 9 files changed, 815 insertions(+), 9 deletions(-) create mode 100644 backend/spec/requests/tag_wiki_history_integrity_spec.rb create mode 100644 backend/spec/requests/wiki_body_search_pending_spec.rb create mode 100644 backend/spec/requests/wiki_conflict_spec.rb create mode 100644 backend/spec/requests/wiki_history_integrity_spec.rb create mode 100644 backend/spec/requests/wiki_restore_pending_spec.rb create mode 100644 backend/spec/requests/wiki_title_collision_spec.rb create mode 100644 backend/spec/services/wiki/commit_integrity_spec.rb create mode 100644 backend/spec/services/wiki_version_recorder_spec.rb diff --git a/backend/app/controllers/tags_controller.rb b/backend/app/controllers/tags_controller.rb index c0d54d1..6a81dd4 100644 --- a/backend/app/controllers/tags_controller.rb +++ b/backend/app/controllers/tags_controller.rb @@ -238,21 +238,26 @@ class TagsController < ApplicationController TagVersioning.ensure_snapshot!(tag, created_by_user: current_user) old_name = tag.name + name_changed = name != old_name + wiki_page = tag.tag_name.wiki_page if name_changed tag.update!(category:) tag.tag_name.update!(name:) - alias_names << old_name if name != old_name + alias_names << old_name if name_changed alias_names.delete(name) update_aliases!(tag, alias_names) update_parent_tags!(tag, parent_names) tag.reload - record_tag_version!(tag, - event_type: :update, - created_by_user: current_user, - name_changed: name.present? && name != old_name) + + record_tag_version!( + tag, + event_type: :update, + created_by_user: current_user, + name_changed:, + wiki_page:) end render json: TagRepr.base(tag.reload) @@ -277,6 +282,7 @@ class TagsController < ApplicationController old_name = tag.name name_changed = name.present? && name != old_name + wiki_page = tag.tag_name.wiki_page if name_changed tag.tag_name.update!(name:) if name.present? tag.update!(category:) if category.present? @@ -287,7 +293,8 @@ class TagsController < ApplicationController tag, event_type: :update, created_by_user: current_user, - name_changed:) + name_changed:, + wiki_page:) end render json: TagRepr.base(tag.reload) @@ -309,7 +316,7 @@ class TagsController < ApplicationController material: material.as_json&.merge(file:, content_type:)) end - def record_tag_version! tag, event_type:, created_by_user:, name_changed: false + def record_tag_version! tag, event_type:, created_by_user:, name_changed: false, wiki_page: nil if tag.nico? NicoTagVersionRecorder.record!(tag:, event_type:, created_by_user:) return @@ -319,10 +326,13 @@ class TagsController < ApplicationController return unless name_changed - wiki_page = tag.tag_name.wiki_page + wiki_page ||= tag.tag_name.wiki_page return unless wiki_page&.wiki_versions&.exists? - WikiVersionRecorder.record!(page: wiki_page, event_type: :update, created_by_user:) + WikiVersionRecorder.record!( + page: wiki_page, + event_type: :update, + created_by_user:) end def update_aliases! tag, alias_names diff --git a/backend/spec/requests/tag_wiki_history_integrity_spec.rb b/backend/spec/requests/tag_wiki_history_integrity_spec.rb new file mode 100644 index 0000000..909ebe3 --- /dev/null +++ b/backend/spec/requests/tag_wiki_history_integrity_spec.rb @@ -0,0 +1,160 @@ +require 'rails_helper' + +RSpec.describe 'Tag and wiki history integrity', type: :request do + let(:member_user) { create(:user, role: 'member') } + + def stub_current_user user + allow_any_instance_of(ApplicationController).to receive(:current_user).and_return(user) + end + + def create_tag! name:, category: :general + tag_name = TagName.create!(name:) + Tag.create!(tag_name:, category:) + end + + def create_wiki_for_tag! tag:, body: 'wiki body', user: member_user + Wiki::Commit.create_content!( + tag_name: tag.tag_name, + body:, + created_by_user: user, + message: 'init') + end + + before do + stub_current_user(member_user) + end + + describe 'PATCH /tags/:id' do + it 'records wiki_version when tag name changes and tag has wiki' do + tag = create_tag!(name: 'patch_tag_wiki_before') + wiki_page = create_wiki_for_tag!(tag:, body: 'wiki body before') + + expect { + patch "/tags/#{ tag.id }", params: { + name: 'patch_tag_wiki_after', + } + } + .to change(TagVersion, :count).by(2) + .and change(WikiVersion, :count).by(1) + + expect(response).to have_http_status(:ok) + + tag.reload + wiki_page.reload + version = wiki_page.wiki_versions.order(:version_no).last + + expect(tag.name).to eq('patch_tag_wiki_after') + expect(wiki_page.title).to eq('patch_tag_wiki_after') + + expect(version).to have_attributes( + event_type: 'update', + title: 'patch_tag_wiki_after', + body: 'wiki body before', + created_by_user_id: member_user.id + ) + end + + it 'does not record wiki_version when only category changes' do + tag = create_tag!(name: 'patch_tag_category_only') + wiki_page = create_wiki_for_tag!(tag:, body: 'wiki body before') + + before_wiki_versions = wiki_page.wiki_versions.count + + expect { + patch "/tags/#{ tag.id }", params: { + category: 'meme', + } + } + .to change(TagVersion, :count).by(2) + + expect(response).to have_http_status(:ok) + + tag.reload + wiki_page.reload + + expect(tag.name).to eq('patch_tag_category_only') + expect(tag.category).to eq('meme') + expect(wiki_page.wiki_versions.count).to eq(before_wiki_versions) + end + end + + describe 'PUT /tags/:id' do + it 'records wiki_version when tag name changes and tag has wiki' do + tag = create_tag!(name: 'put_tag_wiki_before') + wiki_page = create_wiki_for_tag!(tag:, body: 'wiki body before') + + expect { + put "/tags/#{ tag.id }", params: { + name: 'put_tag_wiki_after', + category: 'general', + aliases: '', + parent_tags: '', + } + } + .to change(TagVersion, :count).by(2) + .and change(WikiVersion, :count).by(1) + + expect(response).to have_http_status(:ok) + + tag.reload + wiki_page.reload + version = wiki_page.wiki_versions.order(:version_no).last + + expect(tag.name).to eq('put_tag_wiki_after') + expect(wiki_page.title).to eq('put_tag_wiki_after') + + expect(version).to have_attributes( + event_type: 'update', + title: 'put_tag_wiki_after', + body: 'wiki body before', + created_by_user_id: member_user.id + ) + end + + it 'does not record wiki_version when only category changes' do + tag = create_tag!(name: 'put_tag_category_only') + wiki_page = create_wiki_for_tag!(tag:, body: 'wiki body before') + + before_wiki_versions = wiki_page.wiki_versions.count + + expect { + put "/tags/#{ tag.id }", params: { + name: 'put_tag_category_only', + category: 'meme', + aliases: '', + parent_tags: '', + } + } + .to change(TagVersion, :count).by(2) + + expect(response).to have_http_status(:ok) + + tag.reload + wiki_page.reload + + expect(tag.name).to eq('put_tag_category_only') + expect(tag.category).to eq('meme') + expect(wiki_page.wiki_versions.count).to eq(before_wiki_versions) + end + + it 'does not record wiki_version when only aliases change' do + tag = create_tag!(name: 'put_tag_alias_only') + wiki_page = create_wiki_for_tag!(tag:, body: 'wiki body before') + + before_wiki_versions = wiki_page.wiki_versions.count + + expect { + put "/tags/#{ tag.id }", params: { + name: 'put_tag_alias_only', + category: 'general', + aliases: 'put_tag_alias_only_alias', + parent_tags: '', + } + } + .to change(TagVersion, :count).by(2) + + expect(response).to have_http_status(:ok) + expect(wiki_page.reload.wiki_versions.count).to eq(before_wiki_versions) + end + end +end diff --git a/backend/spec/requests/wiki_body_search_pending_spec.rb b/backend/spec/requests/wiki_body_search_pending_spec.rb new file mode 100644 index 0000000..ebfc874 --- /dev/null +++ b/backend/spec/requests/wiki_body_search_pending_spec.rb @@ -0,0 +1,27 @@ +require 'rails_helper' + +RSpec.describe 'Wiki body search', type: :request do + let!(:user) { create_member_user! } + + it 'searches wiki pages by body text' do + pending 'Wiki 本文検索実装時に有効化する' + + Wiki::Commit.create_content!( + tag_name: TagName.create!(name: 'wiki_body_search_hit'), + body: 'unique body keyword for wiki search', + created_by_user: user, + message: 'init') + + Wiki::Commit.create_content!( + tag_name: TagName.create!(name: 'wiki_body_search_miss'), + body: 'ordinary body', + created_by_user: user, + message: 'init') + + get '/wiki/search', params: { body: 'unique body keyword' } + + expect(response).to have_http_status(:ok) + expect(json.map { |page| page['title'] }).to include('wiki_body_search_hit') + expect(json.map { |page| page['title'] }).not_to include('wiki_body_search_miss') + end +end diff --git a/backend/spec/requests/wiki_conflict_spec.rb b/backend/spec/requests/wiki_conflict_spec.rb new file mode 100644 index 0000000..9ae9052 --- /dev/null +++ b/backend/spec/requests/wiki_conflict_spec.rb @@ -0,0 +1,42 @@ +require 'rails_helper' + +RSpec.describe 'Wiki conflict handling', type: :request do + let!(:user) { create_member_user! } + + def auth_headers user + { 'X-Transfer-Code' => user.inheritance_code } + end + + it 'returns 409 when base_revision_id is stale' do + page = + Wiki::Commit.create_content!( + tag_name: TagName.create!(name: 'wiki_conflict_request'), + body: 'first', + created_by_user: user, + message: 'init') + + stale_id = page.current_revision.id + + Wiki::Commit.content!( + page:, + body: 'second', + created_user: user, + message: 'other edit', + base_revision_id: stale_id) + + put "/wiki/#{ page.id }", + params: { + title: 'wiki_conflict_request', + body: 'third', + message: 'stale', + base_revision_id: stale_id, + }, + headers: auth_headers(user) + + expect(response).to have_http_status(:conflict) + + page.reload + expect(page.body).to eq('second') + expect(page.current_revision.message).to eq('other edit') + end +end diff --git a/backend/spec/requests/wiki_history_integrity_spec.rb b/backend/spec/requests/wiki_history_integrity_spec.rb new file mode 100644 index 0000000..7f2ccce --- /dev/null +++ b/backend/spec/requests/wiki_history_integrity_spec.rb @@ -0,0 +1,196 @@ +require 'cgi' +require 'rails_helper' + +RSpec.describe 'Wiki history integrity', type: :request do + let!(:user) { create_member_user! } + + def auth_headers user + { 'X-Transfer-Code' => user.inheritance_code } + end + + def create_wiki_page title:, body: 'body', message: 'init', user: self.user + Wiki::Commit.create_content!( + tag_name: TagName.create!(name: title), + body:, + created_by_user: user, + message:) + end + + describe 'POST /wiki' do + it 'creates wiki_page, wiki_revision, and wiki_version atomically' do + expect { + post '/wiki', + params: { + title: 'wiki_history_create_atomic', + body: "a\nb\nc", + message: 'initial commit', + }, + headers: auth_headers(user) + } + .to change(WikiPage, :count).by(1) + .and change(WikiRevision, :count).by(1) + .and change(WikiVersion, :count).by(1) + + expect(response).to have_http_status(:created) + + page = WikiPage.find(json.fetch('id')) + revision = page.current_revision + version = page.wiki_versions.order(:version_no).last + + expect(page.title).to eq('wiki_history_create_atomic') + expect(page.body).to eq("a\nb\nc") + + expect(revision).to be_content + expect(revision.message).to eq('initial commit') + expect(revision.lines_count).to eq(3) + + expect(version).to have_attributes( + version_no: 1, + event_type: 'create', + title: 'wiki_history_create_atomic', + body: "a\nb\nc", + reason: 'initial commit', + created_by_user_id: user.id + ) + end + + it 'returns 422 and creates nothing when normalised body is blank' do + expect { + post '/wiki', + params: { + title: 'wiki_history_blank_body', + body: "\r\n\r\n", + message: 'blank', + }, + headers: auth_headers(user) + } + .not_to change(WikiPage, :count) + + expect(response).to have_http_status(:unprocessable_entity) + expect(WikiPage.joins(:tag_name).where(tag_names: { name: 'wiki_history_blank_body' })).not_to exist + end + + it 'returns 422 and creates no partial page when title already exists' do + create_wiki_page(title: 'wiki_history_duplicate_title', body: 'first') + + expect { + post '/wiki', + params: { + title: 'wiki_history_duplicate_title', + body: 'second', + message: 'duplicate', + }, + headers: auth_headers(user) + } + .not_to change(WikiPage, :count) + + expect(response).to have_http_status(:unprocessable_entity) + expect(WikiPage.joins(:tag_name).where(tag_names: { name: 'wiki_history_duplicate_title' }).count).to eq(1) + end + end + + describe 'PUT /wiki/:id' do + it 'updates body and records wiki_revision and wiki_version' do + page = create_wiki_page(title: 'wiki_history_update_body', body: 'before') + current_id = page.current_revision.id + + expect { + put "/wiki/#{ page.id }", + params: { + title: 'wiki_history_update_body', + body: 'after', + message: 'edit body', + base_revision_id: current_id, + }, + headers: auth_headers(user) + } + .to change(WikiRevision, :count).by(1) + .and change(WikiVersion, :count).by(1) + + expect(response).to have_http_status(:ok) + + page.reload + version = page.wiki_versions.order(:version_no).last + + expect(page.title).to eq('wiki_history_update_body') + expect(page.body).to eq('after') + + expect(version).to have_attributes( + event_type: 'update', + title: 'wiki_history_update_body', + body: 'after', + reason: 'edit body', + created_by_user_id: user.id + ) + end + + it 'renames title and records wiki_version with new title' do + page = create_wiki_page(title: 'wiki_history_rename_before', body: 'before') + current_id = page.current_revision.id + + expect { + put "/wiki/#{ page.id }", + params: { + title: 'wiki_history_rename_after', + body: 'after', + message: 'rename', + base_revision_id: current_id, + }, + headers: auth_headers(user) + } + .to change(WikiRevision, :count).by(1) + .and change(WikiVersion, :count).by(1) + + expect(response).to have_http_status(:ok) + + page.reload + version = page.wiki_versions.order(:version_no).last + + expect(page.title).to eq('wiki_history_rename_after') + expect(page.body).to eq('after') + + expect(version).to have_attributes( + event_type: 'update', + title: 'wiki_history_rename_after', + body: 'after', + reason: 'rename', + created_by_user_id: user.id + ) + end + + it 'does not change title, body, revision, or version on stale base_revision_id' do + page = create_wiki_page(title: 'wiki_history_conflict_page', body: 'first') + stale_id = page.current_revision.id + + Wiki::Commit.content!( + page:, + body: 'second', + created_user: user, + message: 'other edit', + base_revision_id: stale_id) + + page.reload + current_title = page.title + current_body = page.body + revision_count = page.wiki_revisions.count + version_count = page.wiki_versions.count + + put "/wiki/#{ page.id }", + params: { + title: 'wiki_history_conflict_renamed', + body: 'third', + message: 'stale edit', + base_revision_id: stale_id, + }, + headers: auth_headers(user) + + expect(response).to have_http_status(:conflict) + + page.reload + expect(page.title).to eq(current_title) + expect(page.body).to eq(current_body) + expect(page.wiki_revisions.count).to eq(revision_count) + expect(page.wiki_versions.count).to eq(version_count) + end + end +end diff --git a/backend/spec/requests/wiki_restore_pending_spec.rb b/backend/spec/requests/wiki_restore_pending_spec.rb new file mode 100644 index 0000000..0ceeca1 --- /dev/null +++ b/backend/spec/requests/wiki_restore_pending_spec.rb @@ -0,0 +1,37 @@ +require 'rails_helper' + +RSpec.describe 'Wiki restore', type: :request do + let!(:user) { create_member_user! } + + def auth_headers user + { 'X-Transfer-Code' => user.inheritance_code } + end + + it 'restores wiki page to previous version' do + pending 'Wiki 版巻き戻し API 実装時に有効化する' + + page = + Wiki::Commit.create_content!( + tag_name: TagName.create!(name: 'wiki_restore_page'), + body: 'v1', + created_by_user: user, + message: 'init') + + v1 = page.wiki_versions.order(:version_no).last + + Wiki::Commit.content!( + page:, + body: 'v2', + created_user: user, + message: 'edit', + base_revision_id: page.current_revision.id) + + post "/wiki/#{ page.id }/restore", + params: { version_no: v1.version_no }, + headers: auth_headers(user) + + expect(response).to have_http_status(:ok) + expect(page.reload.body).to eq('v1') + expect(page.wiki_versions.order(:version_no).last.event_type).to eq('restore') + end +end diff --git a/backend/spec/requests/wiki_title_collision_spec.rb b/backend/spec/requests/wiki_title_collision_spec.rb new file mode 100644 index 0000000..ad02dfa --- /dev/null +++ b/backend/spec/requests/wiki_title_collision_spec.rb @@ -0,0 +1,62 @@ +require 'rails_helper' + +RSpec.describe 'Wiki title collision', type: :request do + let!(:user) { create_member_user! } + + def auth_headers user + { 'X-Transfer-Code' => user.inheritance_code } + end + + def create_wiki_page title:, body: + Wiki::Commit.create_content!( + tag_name: TagName.create!(name: title), + body:, + created_by_user: user, + message: 'init') + end + + it 'returns 422 when renaming wiki title to existing title' do + source = create_wiki_page(title: 'wiki_collision_source', body: 'source body') + create_wiki_page(title: 'wiki_collision_target', body: 'target body') + + source_revision_count = source.wiki_revisions.count + source_version_count = source.wiki_versions.count + old_title = source.title + old_body = source.body + + put "/wiki/#{ source.id }", + params: { + title: 'wiki_collision_target', + body: 'new body', + message: 'rename collision', + base_revision_id: source.current_revision.id, + }, + headers: auth_headers(user) + + expect(response).to have_http_status(:unprocessable_entity) + + source.reload + + expect(source.title).to eq(old_title) + expect(source.body).to eq(old_body) + expect(source.wiki_revisions.count).to eq(source_revision_count) + expect(source.wiki_versions.count).to eq(source_version_count) + end + + it 'returns 422 when creating wiki with existing title' do + create_wiki_page(title: 'wiki_collision_create', body: 'already exists') + + expect { + post '/wiki', + params: { + title: 'wiki_collision_create', + body: 'new body', + message: 'duplicate create', + }, + headers: auth_headers(user) + } + .not_to change(WikiPage, :count) + + expect(response).to have_http_status(:unprocessable_entity) + end +end diff --git a/backend/spec/services/wiki/commit_integrity_spec.rb b/backend/spec/services/wiki/commit_integrity_spec.rb new file mode 100644 index 0000000..4a08de5 --- /dev/null +++ b/backend/spec/services/wiki/commit_integrity_spec.rb @@ -0,0 +1,173 @@ +require 'digest' +require 'rails_helper' + +RSpec.describe Wiki::Commit do + let(:user) { create_member_user! } + + def create_page title:, body: 'initial body' + described_class.create_content!( + tag_name: TagName.create!(name: title), + body:, + created_by_user: user, + message: 'init') + end + + describe '.create_content!' do + it 'creates page, revision, and version with normalised body' do + expect { + described_class.create_content!( + tag_name: TagName.create!(name: 'commit_integrity_create'), + body: "a\r\nb\r\n\r\n", + created_by_user: user, + message: 'init') + } + .to change(WikiPage, :count).by(1) + .and change(WikiRevision, :count).by(1) + .and change(WikiVersion, :count).by(1) + + page = WikiPage.joins(:tag_name).find_by!(tag_names: { name: 'commit_integrity_create' }) + revision = page.current_revision + version = page.wiki_versions.order(:version_no).last + + expect(page.body).to eq("a\nb") + expect(revision.lines_count).to eq(2) + expect(version.body).to eq("a\nb") + expect(version.reason).to eq('init') + end + + it 'rejects body that becomes blank after normalisation' do + tag_name = TagName.create!(name: 'commit_integrity_blank') + + expect { + described_class.create_content!( + tag_name:, + body: "\r\n\r\n", + created_by_user: user, + message: 'blank') + } + .to raise_error(ActiveRecord::RecordInvalid) + + expect(WikiPage.where(tag_name:)).not_to exist + end + end + + describe '.content!' do + it 'updates page body, revision, and version' do + page = create_page(title: 'commit_integrity_update', body: 'before') + current_id = page.current_revision.id + + expect { + described_class.content!( + page:, + body: 'after', + created_user: user, + message: 'edit', + base_revision_id: current_id) + } + .to change(WikiRevision, :count).by(1) + .and change(WikiVersion, :count).by(1) + + page.reload + version = page.wiki_versions.order(:version_no).last + + expect(page.body).to eq('after') + expect(version.body).to eq('after') + expect(version.reason).to eq('edit') + end + + it 'does not record tag_version on body-only wiki update' do + tag_name = TagName.create!(name: 'commit_integrity_linked_tag') + tag = Tag.create!(tag_name:, category: :general) + + page = + described_class.create_content!( + tag_name:, + body: 'before', + created_by_user: user, + message: 'init') + + TagVersionRecorder.record!( + tag:, + event_type: :create, + created_by_user: user) + + before_count = tag.reload.tag_versions.count + + described_class.content!( + page:, + body: 'after', + created_user: user, + message: 'edit', + base_revision_id: page.current_revision.id) + + expect(tag.reload.tag_versions.count).to eq(before_count) + end + + it 'raises conflict and leaves page, revision, and version unchanged' do + page = create_page(title: 'commit_integrity_conflict', body: 'first') + stale_id = page.current_revision.id + + described_class.content!( + page:, + body: 'second', + created_user: user, + message: 'second', + base_revision_id: stale_id) + + page.reload + before_body = page.body + before_revision_count = page.wiki_revisions.count + before_version_count = page.wiki_versions.count + + expect { + described_class.content!( + page:, + body: 'third', + created_user: user, + message: 'stale', + base_revision_id: stale_id) + } + .to raise_error(Wiki::Commit::Conflict) + + page.reload + expect(page.body).to eq(before_body) + expect(page.wiki_revisions.count).to eq(before_revision_count) + expect(page.wiki_versions.count).to eq(before_version_count) + end + + it 'deduplicates duplicated missing wiki lines' do + page = create_page(title: 'commit_integrity_dedup', body: 'before') + duplicated = 'commit_integrity_duplicate_line' + + described_class.content!( + page:, + body: "#{ duplicated }\n#{ duplicated }", + created_user: user, + message: 'dedup', + base_revision_id: page.current_revision.id) + + revision = page.reload.current_revision + + expect(WikiLine.where(body: duplicated).count).to eq(1) + expect(revision.wiki_revision_lines.count).to eq(2) + expect(revision.wiki_revision_lines.pluck(:wiki_line_id).uniq.size).to eq(1) + end + end + + describe '.redirect!' do + it 'raises because redirect revisions are deprecated' do + page = create_page(title: 'commit_integrity_redirect_source', body: 'source') + target = create_page(title: 'commit_integrity_redirect_target', body: 'target') + + expect { + described_class.redirect!( + page:, + redirect_page: target, + created_user: user, + message: 'redirect', + base_revision_id: page.current_revision.id) + } + .to raise_error(RuntimeError, '廃止しました.') + end + end +end diff --git a/backend/spec/services/wiki_version_recorder_spec.rb b/backend/spec/services/wiki_version_recorder_spec.rb new file mode 100644 index 0000000..9f1c6dd --- /dev/null +++ b/backend/spec/services/wiki_version_recorder_spec.rb @@ -0,0 +1,99 @@ +require 'rails_helper' + +RSpec.describe WikiVersionRecorder do + let(:user) { create_member_user! } + + def create_page title:, body: 'body' + Wiki::Commit.create_content!( + tag_name: TagName.create!(name: title), + body:, + created_by_user: user, + message: 'init') + end + + describe '.record!' do + it 'records title, body, reason, user, and version number' do + page = create_page(title: 'wiki_version_recorder_basic', body: 'body') + + expect { + described_class.record!( + page:, + event_type: :update, + reason: 'manual reason', + created_by_user: user) + } + .to change { page.reload.wiki_versions.count }.by(1) + + version = page.wiki_versions.order(:version_no).last + + expect(version).to have_attributes( + version_no: 2, + event_type: 'update', + title: 'wiki_version_recorder_basic', + body: 'body', + reason: 'manual reason', + created_by_user_id: user.id + ) + end + + it 'does not create duplicated update version for identical snapshot' do + page = create_page(title: 'wiki_version_recorder_duplicate', body: 'body') + + described_class.record!( + page:, + event_type: :update, + reason: nil, + created_by_user: user) + + before_count = page.reload.wiki_versions.count + + described_class.record!( + page:, + event_type: :update, + reason: nil, + created_by_user: user) + + expect(page.reload.wiki_versions.count).to eq(before_count) + end + + it 'creates update version when title changes' do + page = create_page(title: 'wiki_version_recorder_title_before', body: 'body') + page.tag_name.update!(name: 'wiki_version_recorder_title_after') + + expect { + described_class.record!( + page:, + event_type: :update, + reason: 'rename', + created_by_user: user) + } + .to change { page.reload.wiki_versions.count }.by(1) + + version = page.wiki_versions.order(:version_no).last + + expect(version.title).to eq('wiki_version_recorder_title_after') + expect(version.body).to eq('body') + expect(version.reason).to eq('rename') + end + + it 'creates update version when body changes' do + page = create_page(title: 'wiki_version_recorder_body', body: 'before') + page.update!(body: 'after') + + expect { + described_class.record!( + page:, + event_type: :update, + reason: 'body', + created_by_user: user) + } + .to change { page.reload.wiki_versions.count }.by(1) + + version = page.wiki_versions.order(:version_no).last + + expect(version.title).to eq('wiki_version_recorder_body') + expect(version.body).to eq('after') + expect(version.reason).to eq('body') + end + end +end -- 2.34.1