#317 #317 #317 #317 #317 #317 Co-authored-by: miteruzo <miteruzo@naver.com> Reviewed-on: https://git.miteruzo.com/miteruzo/btrc-hub/pulls/333feature/329
| @@ -238,18 +238,26 @@ class TagsController < ApplicationController | |||||
| TagVersioning.ensure_snapshot!(tag, created_by_user: current_user) | TagVersioning.ensure_snapshot!(tag, created_by_user: current_user) | ||||
| old_name = tag.name | old_name = tag.name | ||||
| name_changed = name != old_name | |||||
| wiki_page = tag.tag_name.wiki_page if name_changed | |||||
| tag.update!(category:) | tag.update!(category:) | ||||
| tag.tag_name.update!(name:) | tag.tag_name.update!(name:) | ||||
| alias_names << old_name if name != old_name | |||||
| alias_names << old_name if name_changed | |||||
| alias_names.delete(name) | alias_names.delete(name) | ||||
| update_aliases!(tag, alias_names) | update_aliases!(tag, alias_names) | ||||
| update_parent_tags!(tag, parent_names) | update_parent_tags!(tag, parent_names) | ||||
| tag.reload | 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:, | |||||
| wiki_page:) | |||||
| end | end | ||||
| render json: TagRepr.base(tag.reload) | render json: TagRepr.base(tag.reload) | ||||
| @@ -272,10 +280,21 @@ class TagsController < ApplicationController | |||||
| ApplicationRecord.transaction do | ApplicationRecord.transaction do | ||||
| TagVersioning.ensure_snapshot!(tag, created_by_user: current_user) | TagVersioning.ensure_snapshot!(tag, created_by_user: current_user) | ||||
| old_name = tag.name | |||||
| name_changed = name.present? && name != old_name | |||||
| wiki_page = tag.tag_name.wiki_page if name_changed | |||||
| tag.tag_name.update!(name:) if name.present? | tag.tag_name.update!(name:) if name.present? | ||||
| tag.update!(category:) if category.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:, | |||||
| wiki_page:) | |||||
| end | end | ||||
| render json: TagRepr.base(tag.reload) | render json: TagRepr.base(tag.reload) | ||||
| @@ -297,12 +316,23 @@ class TagsController < ApplicationController | |||||
| material: material.as_json&.merge(file:, content_type:)) | material: material.as_json&.merge(file:, content_type:)) | ||||
| end | end | ||||
| def record_tag_version! tag, event_type:, created_by_user: | |||||
| def record_tag_version! tag, event_type:, created_by_user:, name_changed: false, wiki_page: nil | |||||
| if tag.nico? | if tag.nico? | ||||
| NicoTagVersionRecorder.record!(tag:, event_type:, created_by_user:) | NicoTagVersionRecorder.record!(tag:, event_type:, created_by_user:) | ||||
| else | |||||
| TagVersionRecorder.record!(tag:, event_type:, created_by_user:) | |||||
| return | |||||
| end | 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 | end | ||||
| def update_aliases! tag, alias_names | def update_aliases! tag, alias_names | ||||
| @@ -85,22 +85,24 @@ class WikiPagesController < ApplicationController | |||||
| return head :unauthorized unless current_user | return head :unauthorized unless current_user | ||||
| return head :forbidden unless current_user.gte_member? | return head :forbidden unless current_user.gte_member? | ||||
| name = params[:title]&.strip | |||||
| title = params[:title].to_s.strip | |||||
| body = params[:body].to_s | body = params[:body].to_s | ||||
| message = params[:message].presence | |||||
| return head :unprocessable_entity if name.blank? || body.blank? | |||||
| return head :unprocessable_entity if title.blank? || body.blank? | |||||
| tag_name = TagName.find_undiscard_or_create_by!(name:) | |||||
| page = WikiPage.new(tag_name:, created_user: current_user, updated_user: current_user) | |||||
| if page.save | |||||
| message = params[:message].presence | |||||
| Wiki::Commit.content!(page:, body:, created_user: current_user, message:) | |||||
| 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 | end | ||||
| def update | def update | ||||
| @@ -113,18 +115,33 @@ class WikiPagesController < ApplicationController | |||||
| return head :unprocessable_entity if title.blank? || body.blank? | return head :unprocessable_entity if title.blank? || body.blank? | ||||
| page = WikiPage.find(params[:id]) | page = WikiPage.find(params[:id]) | ||||
| base_revision_id = page.current_revision.id | |||||
| base_revision_id = params[:base_revision_id].presence | |||||
| if params[:title].present? && params[:title].strip != page.title | |||||
| return head :unprocessable_entity | |||||
| end | |||||
| ApplicationRecord.transaction do | |||||
| page.lock! | |||||
| message = params[:message].presence | |||||
| Wiki::Commit.content!(page:, | |||||
| body:, | |||||
| created_user: current_user, | |||||
| message:, | |||||
| base_revision_id:) | |||||
| old_title = page.title | |||||
| tag = Tag.find_by(tag_name_id: page.tag_name_id) | |||||
| if tag && title != old_title | |||||
| TagVersioning.ensure_snapshot!(tag, created_by_user: current_user) | |||||
| end | |||||
| page.tag_name.update!(name: title) if title != old_title | |||||
| message = params[:message].presence | |||||
| Wiki::Commit.content!(page:, | |||||
| body:, | |||||
| created_user: current_user, | |||||
| message:, | |||||
| base_revision_id:) | |||||
| if tag && title != old_title | |||||
| tag.reload | |||||
| TagVersionRecorder.record!(tag:, event_type: :update, created_by_user: current_user) | |||||
| end | |||||
| end | |||||
| head :ok | head :ok | ||||
| end | end | ||||
| @@ -2,5 +2,6 @@ class IpAddress < ApplicationRecord | |||||
| validates :ip_address, presence: true, length: { maximum: 16 } | validates :ip_address, presence: true, length: { maximum: 16 } | ||||
| validates :banned, inclusion: { in: [true, false] } | validates :banned, inclusion: { in: [true, false] } | ||||
| has_many :users | |||||
| has_many :user_ips, dependent: :destroy | |||||
| has_many :users, through: :user_ips | |||||
| end | end | ||||
| @@ -13,8 +13,11 @@ class WikiPage < ApplicationRecord | |||||
| foreign_key: :redirect_page_id, | foreign_key: :redirect_page_id, | ||||
| dependent: :nullify | dependent: :nullify | ||||
| has_many :wiki_versions | |||||
| belongs_to :tag_name | belongs_to :tag_name | ||||
| validates :tag_name, presence: true | validates :tag_name, presence: true | ||||
| validates :body, presence: true | |||||
| def title = tag_name.name | def title = tag_name.name | ||||
| @@ -24,11 +27,6 @@ class WikiPage < ApplicationRecord | |||||
| def current_revision = wiki_revisions.order(id: :desc).first | def current_revision = wiki_revisions.order(id: :desc).first | ||||
| def body | |||||
| rev = current_revision | |||||
| rev.body if rev&.content? | |||||
| end | |||||
| def resolve_redirect limit: 10 | def resolve_redirect limit: 10 | ||||
| page = self | page = self | ||||
| visited = Set.new | visited = Set.new | ||||
| @@ -0,0 +1,8 @@ | |||||
| class WikiVersion < ApplicationRecord | |||||
| include VersionRecord | |||||
| belongs_to :wiki_page | |||||
| validates :title, presence: true | |||||
| validates :body, presence: true | |||||
| end | |||||
| @@ -7,6 +7,31 @@ module Wiki | |||||
| ; | ; | ||||
| end | end | ||||
| def self.create_content! tag_name:, body:, created_by_user:, message: nil | |||||
| normalised = normalise_body(body) | |||||
| page = WikiPage.new(tag_name:, | |||||
| body: normalised, | |||||
| created_user: created_by_user, | |||||
| updated_user: created_by_user) | |||||
| if normalised.blank? | |||||
| page.errors.add(:body, :blank) | |||||
| raise ActiveRecord::RecordInvalid, page | |||||
| end | |||||
| ActiveRecord::Base.transaction do | |||||
| page.save! | |||||
| new(page:, created_user: created_by_user).content!( | |||||
| body: normalised, | |||||
| message:, | |||||
| base_revision_id: nil) | |||||
| page | |||||
| end | |||||
| end | |||||
| def self.content! page:, body:, created_user:, message: nil, base_revision_id: nil | def self.content! page:, body:, created_user:, message: nil, base_revision_id: nil | ||||
| new(page:, created_user:).content!(body:, message:, base_revision_id:) | new(page:, created_user:).content!(body:, message:, base_revision_id:) | ||||
| end | end | ||||
| @@ -21,7 +46,12 @@ module Wiki | |||||
| end | end | ||||
| def content! body:, message:, base_revision_id: | def content! body:, message:, base_revision_id: | ||||
| normalised = normalise_body(body) | |||||
| normalised = self.class.normalise_body(body) | |||||
| if normalised.blank? | |||||
| @page.errors.add(:body, :blank) | |||||
| raise ActiveRecord::RecordInvalid, @page | |||||
| end | |||||
| lines = split_lines(normalised) | lines = split_lines(normalised) | ||||
| line_shas = lines.map { |line| Digest::SHA256.hexdigest(line) } | line_shas = lines.map { |line| Digest::SHA256.hexdigest(line) } | ||||
| @@ -37,10 +67,19 @@ module Wiki | |||||
| current_id = @page.wiki_revisions.maximum(:id) | current_id = @page.wiki_revisions.maximum(:id) | ||||
| if current_id && current_id != base_revision_id.to_i | if current_id && current_id != base_revision_id.to_i | ||||
| raise Conflict, | raise Conflict, | ||||
| "競合が発生してゐます(現在の Id.:#{ current_id },ベース Id.:#{ base_revision_id })." | |||||
| "競合が発生してゐます" + | |||||
| "(現在の Id.:#{ current_id },ベース Id.:#{ base_revision_id })." | |||||
| end | end | ||||
| end | end | ||||
| @page.update!(body: normalised) | |||||
| WikiVersionRecorder.record!( | |||||
| page: @page, | |||||
| event_type: @page.wiki_versions.exists? ? :update : :create, | |||||
| reason: message, | |||||
| created_by_user: @created_user) | |||||
| rev = WikiRevision.create!( | rev = WikiRevision.create!( | ||||
| wiki_page: @page, | wiki_page: @page, | ||||
| base_revision_id:, | base_revision_id:, | ||||
| @@ -54,65 +93,45 @@ module Wiki | |||||
| rows = line_ids.each_with_index.map do |line_id, pos| | rows = line_ids.each_with_index.map do |line_id, pos| | ||||
| { wiki_revision_id: rev.id, wiki_line_id: line_id, position: pos } | { wiki_revision_id: rev.id, wiki_line_id: line_id, position: pos } | ||||
| end | end | ||||
| WikiRevisionLine.insert_all!(rows) | |||||
| WikiRevisionLine.insert_all!(rows) if rows.any? | |||||
| rev | rev | ||||
| end | end | ||||
| end | end | ||||
| def redirect! redirect_page:, message:, base_revision_id: | |||||
| ActiveRecord::Base.transaction do | |||||
| @page.lock! | |||||
| def redirect!(redirect_page:, message:, base_revision_id:) = raise '廃止しました.' | |||||
| 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 | |||||
| private | |||||
| def normalise_body body | |||||
| def self.normalise_body body | |||||
| s = body.to_s | s = body.to_s | ||||
| s.gsub!("\r\n", "\n") | |||||
| s.gsub!(/\r\n?/, "\n") | |||||
| s.encode('UTF-8', invalid: :replace, undef: :replace, replace: '🖕') | s.encode('UTF-8', invalid: :replace, undef: :replace, replace: '🖕') | ||||
| s.gsub(/\n+$/, '') | |||||
| end | end | ||||
| def split_lines body | |||||
| body.split("\n") | |||||
| end | |||||
| private | |||||
| def split_lines(body) = body.split("\n") | |||||
| def upsert_lines! lines, line_shas | def upsert_lines! lines, line_shas | ||||
| now = Time.current | now = Time.current | ||||
| id_by_sha = WikiLine.where(sha256: line_shas).pluck(:sha256, :id).to_h | id_by_sha = WikiLine.where(sha256: line_shas).pluck(:sha256, :id).to_h | ||||
| missing_rows = [] | |||||
| missing_by_sha = { } | |||||
| line_shas.each_with_index do |sha, i| | line_shas.each_with_index do |sha, i| | ||||
| next if id_by_sha.key?(sha) | next if id_by_sha.key?(sha) | ||||
| next if missing_by_sha.key?(sha) | |||||
| missing_rows << { sha256: sha, | |||||
| 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 | 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 | id_by_sha = WikiLine.where(sha256: line_shas).pluck(:sha256, :id).to_h | ||||
| end | end | ||||
| @@ -0,0 +1,21 @@ | |||||
| class WikiVersionRecorder < VersionRecorder | |||||
| def self.record! page:, event_type:, reason: nil, created_by_user: | |||||
| new(page:, event_type:, reason:, created_by_user:).record! | |||||
| end | |||||
| def initialize page:, event_type:, reason: nil, created_by_user: | |||||
| @reason = reason | |||||
| super(record: page, event_type:, created_by_user:) | |||||
| end | |||||
| private | |||||
| def version_class = WikiVersion | |||||
| def version_association = :wiki_versions | |||||
| def record_key = :wiki_page | |||||
| def snapshot_attributes = { | |||||
| title: @record.title, | |||||
| body: @record.body, | |||||
| reason: @reason } | |||||
| end | |||||
| @@ -0,0 +1,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 | |||||
| @@ -10,7 +10,7 @@ | |||||
| # | # | ||||
| # It's strongly recommended that you check this file into your version control system. | # It's strongly recommended that you check this file into your version control system. | ||||
| ActiveRecord::Schema[8.0].define(version: 2026_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| | create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| | ||||
| t.string "name", null: false | t.string "name", null: false | ||||
| t.string "record_type", null: false | t.string "record_type", null: false | ||||
| @@ -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| | create_table "wiki_pages", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| | ||||
| t.bigint "tag_name_id", null: false | t.bigint "tag_name_id", null: false | ||||
| t.text "body", null: false | |||||
| t.bigint "created_user_id", null: false | t.bigint "created_user_id", null: false | ||||
| t.bigint "updated_user_id", null: false | t.bigint "updated_user_id", null: false | ||||
| t.datetime "created_at", null: false | t.datetime "created_at", null: false | ||||
| @@ -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" | t.index ["wiki_page_id"], name: "index_wiki_revisions_on_wiki_page_id" | ||||
| end | end | ||||
| create_table "wiki_versions", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| | |||||
| t.bigint "wiki_page_id", null: false | |||||
| t.integer "version_no", null: false | |||||
| t.string "event_type", null: false | |||||
| t.string "title", null: false | |||||
| t.text "body", null: false | |||||
| t.text "reason" | |||||
| t.datetime "created_at", null: false | |||||
| t.bigint "created_by_user_id" | |||||
| t.index ["created_by_user_id"], name: "index_wiki_versions_on_created_by_user_id" | |||||
| t.index ["wiki_page_id", "version_no"], name: "index_wiki_versions_on_wiki_page_id_and_version_no", unique: true | |||||
| t.index ["wiki_page_id"], name: "index_wiki_versions_on_wiki_page_id" | |||||
| t.check_constraint "`event_type` in (_utf8mb4'create',_utf8mb4'update',_utf8mb4'discard',_utf8mb4'restore')", name: "wiki_versions_event_type_valid" | |||||
| t.check_constraint "`version_no` > 0", name: "wiki_versions_version_no_positive" | |||||
| end | |||||
| add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id" | add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id" | ||||
| add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id" | add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id" | ||||
| add_foreign_key "material_versions", "materials" | add_foreign_key "material_versions", "materials" | ||||
| @@ -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" | ||||
| add_foreign_key "wiki_revisions", "wiki_pages", column: "redirect_page_id" | add_foreign_key "wiki_revisions", "wiki_pages", column: "redirect_page_id" | ||||
| add_foreign_key "wiki_revisions", "wiki_revisions", column: "base_revision_id" | add_foreign_key "wiki_revisions", "wiki_revisions", column: "base_revision_id" | ||||
| add_foreign_key "wiki_versions", "users", column: "created_by_user_id" | |||||
| add_foreign_key "wiki_versions", "wiki_pages" | |||||
| end | end | ||||
| @@ -3,5 +3,7 @@ FactoryBot.define do | |||||
| title { "TestPage" } | title { "TestPage" } | ||||
| association :created_user, factory: :user | association :created_user, factory: :user | ||||
| association :updated_user, factory: :user | association :updated_user, factory: :user | ||||
| body { ' ' } | |||||
| end | end | ||||
| end | end | ||||
| @@ -107,11 +107,13 @@ RSpec.describe Tag, type: :model do | |||||
| context 'when the source tag_name has a wiki_page' do | context 'when the source tag_name has a wiki_page' do | ||||
| let!(:source_post_tag) { PostTag.create!(post: post_record, tag: source_tag) } | let!(:source_post_tag) { PostTag.create!(post: post_record, tag: source_tag) } | ||||
| let!(:wiki_page) do | let!(:wiki_page) do | ||||
| WikiPage.create!( | |||||
| 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 | end | ||||
| it 'rolls back the transaction' do | it 'rolls back the transaction' do | ||||
| @@ -214,5 +214,30 @@ RSpec.describe 'TagVersions API', type: :request do | |||||
| expect(versions.size).to eq(1) | expect(versions.size).to eq(1) | ||||
| expect(versions.first['version_no']).to eq(2) | expect(versions.first['version_no']).to eq(2) | ||||
| end | end | ||||
| it 'does not create tag versions by wiki updates when tag has no versions yet' do | |||||
| wiki_tag_name = TagName.create!(name: 'tag_versions_from_wiki') | |||||
| wiki_tag = Tag.create!(tag_name: wiki_tag_name, category: :general) | |||||
| wiki_page = | |||||
| Wiki::Commit.create_content!( | |||||
| tag_name: wiki_tag_name, | |||||
| body: 'before', | |||||
| created_by_user: member, | |||||
| message: 'init') | |||||
| Wiki::Commit.content!( | |||||
| page: wiki_page, | |||||
| body: 'after', | |||||
| created_user: member, | |||||
| message: 'edit', | |||||
| base_revision_id: wiki_page.current_revision.id) | |||||
| get '/tags/versions', params: { id: wiki_tag.id } | |||||
| expect(response).to have_http_status(:ok) | |||||
| expect(json.fetch('versions')).to eq([]) | |||||
| expect(json.fetch('count')).to eq(0) | |||||
| end | |||||
| end | end | ||||
| end | end | ||||
| @@ -0,0 +1,160 @@ | |||||
| require 'rails_helper' | |||||
| RSpec.describe 'Tag and wiki history integrity', type: :request do | |||||
| let(:member_user) { create(:user, role: 'member') } | |||||
| def stub_current_user user | |||||
| allow_any_instance_of(ApplicationController).to receive(:current_user).and_return(user) | |||||
| end | |||||
| def create_tag! name:, category: :general | |||||
| tag_name = TagName.create!(name:) | |||||
| Tag.create!(tag_name:, category:) | |||||
| end | |||||
| def create_wiki_for_tag! tag:, body: 'wiki body', user: member_user | |||||
| Wiki::Commit.create_content!( | |||||
| tag_name: tag.tag_name, | |||||
| body:, | |||||
| created_by_user: user, | |||||
| message: 'init') | |||||
| end | |||||
| before do | |||||
| stub_current_user(member_user) | |||||
| end | |||||
| describe 'PATCH /tags/:id' do | |||||
| it 'records wiki_version when tag name changes and tag has wiki' do | |||||
| tag = create_tag!(name: 'patch_tag_wiki_before') | |||||
| wiki_page = create_wiki_for_tag!(tag:, body: 'wiki body before') | |||||
| expect { | |||||
| patch "/tags/#{ tag.id }", params: { | |||||
| name: 'patch_tag_wiki_after', | |||||
| } | |||||
| } | |||||
| .to change(TagVersion, :count).by(2) | |||||
| .and change(WikiVersion, :count).by(1) | |||||
| expect(response).to have_http_status(:ok) | |||||
| tag.reload | |||||
| wiki_page.reload | |||||
| version = wiki_page.wiki_versions.order(:version_no).last | |||||
| expect(tag.name).to eq('patch_tag_wiki_after') | |||||
| expect(wiki_page.title).to eq('patch_tag_wiki_after') | |||||
| expect(version).to have_attributes( | |||||
| event_type: 'update', | |||||
| title: 'patch_tag_wiki_after', | |||||
| body: 'wiki body before', | |||||
| created_by_user_id: member_user.id | |||||
| ) | |||||
| end | |||||
| it 'does not record wiki_version when only category changes' do | |||||
| tag = create_tag!(name: 'patch_tag_category_only') | |||||
| wiki_page = create_wiki_for_tag!(tag:, body: 'wiki body before') | |||||
| before_wiki_versions = wiki_page.wiki_versions.count | |||||
| expect { | |||||
| patch "/tags/#{ tag.id }", params: { | |||||
| category: 'meme', | |||||
| } | |||||
| } | |||||
| .to change(TagVersion, :count).by(2) | |||||
| expect(response).to have_http_status(:ok) | |||||
| tag.reload | |||||
| wiki_page.reload | |||||
| expect(tag.name).to eq('patch_tag_category_only') | |||||
| expect(tag.category).to eq('meme') | |||||
| expect(wiki_page.wiki_versions.count).to eq(before_wiki_versions) | |||||
| end | |||||
| end | |||||
| describe 'PUT /tags/:id' do | |||||
| it 'records wiki_version when tag name changes and tag has wiki' do | |||||
| tag = create_tag!(name: 'put_tag_wiki_before') | |||||
| wiki_page = create_wiki_for_tag!(tag:, body: 'wiki body before') | |||||
| expect { | |||||
| put "/tags/#{ tag.id }", params: { | |||||
| name: 'put_tag_wiki_after', | |||||
| category: 'general', | |||||
| aliases: '', | |||||
| parent_tags: '', | |||||
| } | |||||
| } | |||||
| .to change(TagVersion, :count).by(2) | |||||
| .and change(WikiVersion, :count).by(1) | |||||
| expect(response).to have_http_status(:ok) | |||||
| tag.reload | |||||
| wiki_page.reload | |||||
| version = wiki_page.wiki_versions.order(:version_no).last | |||||
| expect(tag.name).to eq('put_tag_wiki_after') | |||||
| expect(wiki_page.title).to eq('put_tag_wiki_after') | |||||
| expect(version).to have_attributes( | |||||
| event_type: 'update', | |||||
| title: 'put_tag_wiki_after', | |||||
| body: 'wiki body before', | |||||
| created_by_user_id: member_user.id | |||||
| ) | |||||
| end | |||||
| it 'does not record wiki_version when only category changes' do | |||||
| tag = create_tag!(name: 'put_tag_category_only') | |||||
| wiki_page = create_wiki_for_tag!(tag:, body: 'wiki body before') | |||||
| before_wiki_versions = wiki_page.wiki_versions.count | |||||
| expect { | |||||
| put "/tags/#{ tag.id }", params: { | |||||
| name: 'put_tag_category_only', | |||||
| category: 'meme', | |||||
| aliases: '', | |||||
| parent_tags: '', | |||||
| } | |||||
| } | |||||
| .to change(TagVersion, :count).by(2) | |||||
| expect(response).to have_http_status(:ok) | |||||
| tag.reload | |||||
| wiki_page.reload | |||||
| expect(tag.name).to eq('put_tag_category_only') | |||||
| expect(tag.category).to eq('meme') | |||||
| expect(wiki_page.wiki_versions.count).to eq(before_wiki_versions) | |||||
| end | |||||
| it 'does not record wiki_version when only aliases change' do | |||||
| tag = create_tag!(name: 'put_tag_alias_only') | |||||
| wiki_page = create_wiki_for_tag!(tag:, body: 'wiki body before') | |||||
| before_wiki_versions = wiki_page.wiki_versions.count | |||||
| expect { | |||||
| put "/tags/#{ tag.id }", params: { | |||||
| name: 'put_tag_alias_only', | |||||
| category: 'general', | |||||
| aliases: 'put_tag_alias_only_alias', | |||||
| parent_tags: '', | |||||
| } | |||||
| } | |||||
| .to change(TagVersion, :count).by(2) | |||||
| expect(response).to have_http_status(:ok) | |||||
| expect(wiki_page.reload.wiki_versions.count).to eq(before_wiki_versions) | |||||
| end | |||||
| end | |||||
| end | |||||
| @@ -471,6 +471,54 @@ RSpec.describe 'Tags API', type: :request do | |||||
| expect(response).to have_http_status(:unprocessable_entity) | expect(response).to have_http_status(:unprocessable_entity) | ||||
| expect(nico_tag.reload.category).to eq('nico') | 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_wiki_version_count = wiki_page.reload.wiki_versions.count | |||||
| expect { | |||||
| patch "/tags/#{ tag.id }", params: { | |||||
| category: 'meme', | |||||
| } | |||||
| }.to change(TagVersion, :count).by(2) | |||||
| expect(response).to have_http_status(:ok) | |||||
| expect(wiki_page.reload.wiki_versions.count).to eq(before_wiki_version_count) | |||||
| end | |||||
| end | end | ||||
| end | end | ||||
| @@ -876,6 +924,136 @@ RSpec.describe 'Tags API', type: :request do | |||||
| expect(system_tag.reload.name).to eq(old_name) | expect(system_tag.reload.name).to eq(old_name) | ||||
| expect(system_tag.category).to eq(old_category) | expect(system_tag.category).to eq(old_category) | ||||
| end | 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 | |||||
| it 'tag の name を変更すると対応する wiki version を作成する' do | |||||
| wiki_page = | |||||
| Wiki::Commit.create_content!( | |||||
| tag_name: tag.tag_name, | |||||
| body: 'wiki body before', | |||||
| created_by_user: member_user, | |||||
| message: 'init') | |||||
| expect { | |||||
| put "/tags/#{ tag.id }", params: { | |||||
| name: 'put_wiki_renamed_tag', | |||||
| category: 'general', | |||||
| aliases: 'unko', | |||||
| parent_tags: '', | |||||
| } | |||||
| } | |||||
| .to change(TagVersion, :count).by(2) | |||||
| .and change(WikiVersion, :count).by(1) | |||||
| expect(response).to have_http_status(:ok) | |||||
| version = wiki_page.reload.wiki_versions.order(:version_no).last | |||||
| expect(version).to have_attributes( | |||||
| event_type: 'update', | |||||
| title: 'put_wiki_renamed_tag', | |||||
| body: 'wiki body before', | |||||
| created_by_user_id: member_user.id | |||||
| ) | |||||
| end | |||||
| end | end | ||||
| end | end | ||||
| end | end | ||||
| @@ -0,0 +1,27 @@ | |||||
| require 'rails_helper' | |||||
| RSpec.describe 'Wiki body search', type: :request do | |||||
| let!(:user) { create_member_user! } | |||||
| it 'searches wiki pages by body text' do | |||||
| pending '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 | |||||
| @@ -0,0 +1,42 @@ | |||||
| require 'rails_helper' | |||||
| RSpec.describe 'Wiki conflict handling', type: :request do | |||||
| let!(:user) { create_member_user! } | |||||
| def auth_headers user | |||||
| { 'X-Transfer-Code' => user.inheritance_code } | |||||
| end | |||||
| it 'returns 409 when base_revision_id is stale' do | |||||
| page = | |||||
| Wiki::Commit.create_content!( | |||||
| tag_name: TagName.create!(name: 'wiki_conflict_request'), | |||||
| body: 'first', | |||||
| created_by_user: user, | |||||
| message: 'init') | |||||
| stale_id = page.current_revision.id | |||||
| Wiki::Commit.content!( | |||||
| page:, | |||||
| body: 'second', | |||||
| created_user: user, | |||||
| message: 'other edit', | |||||
| base_revision_id: stale_id) | |||||
| put "/wiki/#{ page.id }", | |||||
| params: { | |||||
| title: 'wiki_conflict_request', | |||||
| body: 'third', | |||||
| message: 'stale', | |||||
| base_revision_id: stale_id, | |||||
| }, | |||||
| headers: auth_headers(user) | |||||
| expect(response).to have_http_status(:conflict) | |||||
| page.reload | |||||
| expect(page.body).to eq('second') | |||||
| expect(page.current_revision.message).to eq('other edit') | |||||
| end | |||||
| end | |||||
| @@ -0,0 +1,196 @@ | |||||
| require 'cgi' | |||||
| require 'rails_helper' | |||||
| RSpec.describe 'Wiki history integrity', type: :request do | |||||
| let!(:user) { create_member_user! } | |||||
| def auth_headers user | |||||
| { 'X-Transfer-Code' => user.inheritance_code } | |||||
| end | |||||
| def create_wiki_page title:, body: 'body', message: 'init', user: self.user | |||||
| Wiki::Commit.create_content!( | |||||
| tag_name: TagName.create!(name: title), | |||||
| body:, | |||||
| created_by_user: user, | |||||
| message:) | |||||
| end | |||||
| describe 'POST /wiki' do | |||||
| it 'creates wiki_page, wiki_revision, and wiki_version atomically' do | |||||
| expect { | |||||
| post '/wiki', | |||||
| params: { | |||||
| title: 'wiki_history_create_atomic', | |||||
| body: "a\nb\nc", | |||||
| message: 'initial commit', | |||||
| }, | |||||
| headers: auth_headers(user) | |||||
| } | |||||
| .to change(WikiPage, :count).by(1) | |||||
| .and change(WikiRevision, :count).by(1) | |||||
| .and change(WikiVersion, :count).by(1) | |||||
| expect(response).to have_http_status(:created) | |||||
| page = WikiPage.find(json.fetch('id')) | |||||
| revision = page.current_revision | |||||
| version = page.wiki_versions.order(:version_no).last | |||||
| expect(page.title).to eq('wiki_history_create_atomic') | |||||
| expect(page.body).to eq("a\nb\nc") | |||||
| expect(revision).to be_content | |||||
| expect(revision.message).to eq('initial commit') | |||||
| expect(revision.lines_count).to eq(3) | |||||
| expect(version).to have_attributes( | |||||
| version_no: 1, | |||||
| event_type: 'create', | |||||
| title: 'wiki_history_create_atomic', | |||||
| body: "a\nb\nc", | |||||
| reason: 'initial commit', | |||||
| created_by_user_id: user.id | |||||
| ) | |||||
| end | |||||
| it 'returns 422 and creates nothing when normalised body is blank' do | |||||
| expect { | |||||
| post '/wiki', | |||||
| params: { | |||||
| title: 'wiki_history_blank_body', | |||||
| body: "\r\n\r\n", | |||||
| message: 'blank', | |||||
| }, | |||||
| headers: auth_headers(user) | |||||
| } | |||||
| .not_to change(WikiPage, :count) | |||||
| expect(response).to have_http_status(:unprocessable_entity) | |||||
| expect(WikiPage.joins(:tag_name).where(tag_names: { name: 'wiki_history_blank_body' })).not_to exist | |||||
| end | |||||
| it 'returns 422 and creates no partial page when title already exists' do | |||||
| create_wiki_page(title: 'wiki_history_duplicate_title', body: 'first') | |||||
| expect { | |||||
| post '/wiki', | |||||
| params: { | |||||
| title: 'wiki_history_duplicate_title', | |||||
| body: 'second', | |||||
| message: 'duplicate', | |||||
| }, | |||||
| headers: auth_headers(user) | |||||
| } | |||||
| .not_to change(WikiPage, :count) | |||||
| expect(response).to have_http_status(:unprocessable_entity) | |||||
| expect(WikiPage.joins(:tag_name).where(tag_names: { name: 'wiki_history_duplicate_title' }).count).to eq(1) | |||||
| end | |||||
| end | |||||
| describe 'PUT /wiki/:id' do | |||||
| it 'updates body and records wiki_revision and wiki_version' do | |||||
| page = create_wiki_page(title: 'wiki_history_update_body', body: 'before') | |||||
| current_id = page.current_revision.id | |||||
| expect { | |||||
| put "/wiki/#{ page.id }", | |||||
| params: { | |||||
| title: 'wiki_history_update_body', | |||||
| body: 'after', | |||||
| message: 'edit body', | |||||
| base_revision_id: current_id, | |||||
| }, | |||||
| headers: auth_headers(user) | |||||
| } | |||||
| .to change(WikiRevision, :count).by(1) | |||||
| .and change(WikiVersion, :count).by(1) | |||||
| expect(response).to have_http_status(:ok) | |||||
| page.reload | |||||
| version = page.wiki_versions.order(:version_no).last | |||||
| expect(page.title).to eq('wiki_history_update_body') | |||||
| expect(page.body).to eq('after') | |||||
| expect(version).to have_attributes( | |||||
| event_type: 'update', | |||||
| title: 'wiki_history_update_body', | |||||
| body: 'after', | |||||
| reason: 'edit body', | |||||
| created_by_user_id: user.id | |||||
| ) | |||||
| end | |||||
| it 'renames title and records wiki_version with new title' do | |||||
| page = create_wiki_page(title: 'wiki_history_rename_before', body: 'before') | |||||
| current_id = page.current_revision.id | |||||
| expect { | |||||
| put "/wiki/#{ page.id }", | |||||
| params: { | |||||
| title: 'wiki_history_rename_after', | |||||
| body: 'after', | |||||
| message: 'rename', | |||||
| base_revision_id: current_id, | |||||
| }, | |||||
| headers: auth_headers(user) | |||||
| } | |||||
| .to change(WikiRevision, :count).by(1) | |||||
| .and change(WikiVersion, :count).by(1) | |||||
| expect(response).to have_http_status(:ok) | |||||
| page.reload | |||||
| version = page.wiki_versions.order(:version_no).last | |||||
| expect(page.title).to eq('wiki_history_rename_after') | |||||
| expect(page.body).to eq('after') | |||||
| expect(version).to have_attributes( | |||||
| event_type: 'update', | |||||
| title: 'wiki_history_rename_after', | |||||
| body: 'after', | |||||
| reason: 'rename', | |||||
| created_by_user_id: user.id | |||||
| ) | |||||
| end | |||||
| it 'does not change title, body, revision, or version on stale base_revision_id' do | |||||
| page = create_wiki_page(title: 'wiki_history_conflict_page', body: 'first') | |||||
| stale_id = page.current_revision.id | |||||
| Wiki::Commit.content!( | |||||
| page:, | |||||
| body: 'second', | |||||
| created_user: user, | |||||
| message: 'other edit', | |||||
| base_revision_id: stale_id) | |||||
| page.reload | |||||
| current_title = page.title | |||||
| current_body = page.body | |||||
| revision_count = page.wiki_revisions.count | |||||
| version_count = page.wiki_versions.count | |||||
| put "/wiki/#{ page.id }", | |||||
| params: { | |||||
| title: 'wiki_history_conflict_renamed', | |||||
| body: 'third', | |||||
| message: 'stale edit', | |||||
| base_revision_id: stale_id, | |||||
| }, | |||||
| headers: auth_headers(user) | |||||
| expect(response).to have_http_status(:conflict) | |||||
| page.reload | |||||
| expect(page.title).to eq(current_title) | |||||
| expect(page.body).to eq(current_body) | |||||
| expect(page.wiki_revisions.count).to eq(revision_count) | |||||
| expect(page.wiki_versions.count).to eq(version_count) | |||||
| end | |||||
| end | |||||
| end | |||||
| @@ -0,0 +1,37 @@ | |||||
| require 'rails_helper' | |||||
| RSpec.describe 'Wiki restore', type: :request do | |||||
| let!(:user) { create_member_user! } | |||||
| def auth_headers user | |||||
| { 'X-Transfer-Code' => user.inheritance_code } | |||||
| end | |||||
| it 'restores wiki page to previous version' do | |||||
| pending '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 | |||||
| @@ -4,13 +4,19 @@ require 'securerandom' | |||||
| RSpec.describe 'Wiki API', type: :request do | RSpec.describe 'Wiki API', type: :request do | ||||
| def auth_headers(user) | |||||
| { 'X-Transfer-Code' => user.inheritance_code } | |||||
| end | |||||
| let!(:user) { create_member_user! } | let!(:user) { create_member_user! } | ||||
| let!(:tn) { TagName.create!(name: 'spec_wiki_title') } | let!(:tn) { TagName.create!(name: 'spec_wiki_title') } | ||||
| let!(:page) do | let!(:page) do | ||||
| WikiPage.create!(tag_name: tn, created_user: user, updated_user: user).tap do |p| | |||||
| Wiki::Commit.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 | end | ||||
| describe 'GET /wiki' do | describe 'GET /wiki' do | ||||
| @@ -37,11 +43,12 @@ RSpec.describe 'Wiki API', type: :request do | |||||
| context 'when wiki page exists' do | context 'when wiki page exists' do | ||||
| it 'returns wiki page with title' do | it 'returns wiki page with title' do | ||||
| request | request | ||||
| expect(response).to have_http_status(:ok) | expect(response).to have_http_status(:ok) | ||||
| expect(json).to include( | expect(json).to include( | ||||
| 'id' => page.id, | |||||
| 'title' => 'spec_wiki_title') | |||||
| 'id' => page.id, | |||||
| 'title' => 'spec_wiki_title') | |||||
| end | end | ||||
| end | end | ||||
| @@ -50,6 +57,7 @@ RSpec.describe 'Wiki API', type: :request do | |||||
| it 'returns 404' do | it 'returns 404' do | ||||
| request | request | ||||
| expect(response).to have_http_status(:not_found) | expect(response).to have_http_status(:not_found) | ||||
| end | end | ||||
| end | end | ||||
| @@ -99,25 +107,34 @@ RSpec.describe 'Wiki API', type: :request do | |||||
| end | end | ||||
| .to change(WikiPage, :count).by(1) | .to change(WikiPage, :count).by(1) | ||||
| .and change(WikiRevision, :count).by(1) | .and change(WikiRevision, :count).by(1) | ||||
| .and change(WikiVersion, :count).by(1) | |||||
| expect(response).to have_http_status(:created) | expect(response).to have_http_status(:created) | ||||
| page_id = json.fetch('id') | page_id = json.fetch('id') | ||||
| expect(json.fetch('title')).to eq('TestPage') | expect(json.fetch('title')).to eq('TestPage') | ||||
| page = WikiPage.find(page_id) | |||||
| rev = page.current_revision | |||||
| created_page = WikiPage.find(page_id) | |||||
| version = created_page.wiki_versions.order(:version_no).last | |||||
| expect(version).to have_attributes( | |||||
| version_no: 1, | |||||
| event_type: 'create', | |||||
| title: 'TestPage', | |||||
| body: "a\nb\nc", | |||||
| created_by_user_id: member.id | |||||
| ) | |||||
| rev = created_page.current_revision | |||||
| expect(rev).to be_present | expect(rev).to be_present | ||||
| expect(rev).to be_content | expect(rev).to be_content | ||||
| expect(rev.message).to eq('init') | expect(rev.message).to eq('init') | ||||
| # body が復元できること | |||||
| expect(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.lines_count).to eq(3) | ||||
| expect(rev.wiki_revision_lines.order(:position).pluck(:position)).to eq([0, 1, 2]) | expect(rev.wiki_revision_lines.order(:position).pluck(:position)).to eq([0, 1, 2]) | ||||
| expect(rev.wiki_lines.pluck(:body)).to match_array(%w[a b c]) | |||||
| expect(rev.wiki_lines.pluck(:body)).to match_array(['a', 'b', 'c']) | |||||
| end | end | ||||
| it 'reuses existing WikiLine rows by sha256' do | it 'reuses existing WikiLine rows by sha256' do | ||||
| @@ -135,6 +152,41 @@ RSpec.describe 'Wiki API', type: :request do | |||||
| # "a" の WikiLine が増殖しない(1行のはず) | # "a" の WikiLine が増殖しない(1行のはず) | ||||
| expect(WikiLine.where(body: 'a').count).to eq(1) | expect(WikiLine.where(body: 'a').count).to eq(1) | ||||
| end | end | ||||
| it 'deduplicates duplicated new lines before upsert' do | |||||
| duplicated = 'duplicated_line_for_wiki_line_upsert_spec' | |||||
| post endpoint, | |||||
| params: { title: 'DuplicateNewLine', body: "#{ duplicated }\n#{ duplicated }" }, | |||||
| headers: auth_headers(member) | |||||
| expect(response).to have_http_status(:created) | |||||
| page = WikiPage.find(json.fetch('id')) | |||||
| rev = page.current_revision | |||||
| expect(rev.lines_count).to eq(2) | |||||
| expect(WikiLine.where(body: duplicated).count).to eq(1) | |||||
| expect(rev.wiki_revision_lines.count).to eq(2) | |||||
| expect(rev.wiki_revision_lines.pluck(:wiki_line_id).uniq.size).to eq(1) | |||||
| end | |||||
| it 'normalises CRLF and strips trailing newlines' do | |||||
| post endpoint, | |||||
| params: { title: 'NormalisedBody', body: "a\r\nb\r\n\r\n", message: 'normalise' }, | |||||
| headers: auth_headers(member) | |||||
| expect(response).to have_http_status(:created) | |||||
| page = WikiPage.find(json.fetch('id')) | |||||
| rev = page.current_revision | |||||
| version = page.wiki_versions.order(:version_no).last | |||||
| expect(page.body).to eq("a\nb") | |||||
| expect(version.body).to eq("a\nb") | |||||
| expect(rev.lines_count).to eq(2) | |||||
| expect(rev.wiki_lines.order('wiki_revision_lines.position').map(&:body)).to eq(['a', 'b']) | |||||
| end | |||||
| end | end | ||||
| end | end | ||||
| @@ -146,17 +198,14 @@ RSpec.describe 'Wiki API', type: :request do | |||||
| { 'X-Transfer-Code' => user.inheritance_code } | { 'X-Transfer-Code' => user.inheritance_code } | ||||
| end | end | ||||
| #let!(:page) { create(:wiki_page, title: 'TestPage') } | |||||
| let!(: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 | end | ||||
| context 'when not logged in' do | context 'when not logged in' do | ||||
| @@ -182,14 +231,6 @@ RSpec.describe 'Wiki API', type: :request do | |||||
| headers: auth_headers(member) | headers: auth_headers(member) | ||||
| expect(response).to have_http_status(:unprocessable_entity) | expect(response).to have_http_status(:unprocessable_entity) | ||||
| end | end | ||||
| it 'returns 422 when title mismatched (if you forbid rename here)' do | |||||
| put "/wiki/#{page.id}", | |||||
| params: { title: 'OtherTitle', body: 'x' }, | |||||
| headers: auth_headers(member) | |||||
| # 君の controller 例だと title 変更は 422 にしてた | |||||
| expect(response).to have_http_status(:unprocessable_entity) | |||||
| end | |||||
| end | end | ||||
| context 'when success' do | context 'when success' do | ||||
| @@ -200,7 +241,18 @@ RSpec.describe 'Wiki API', type: :request do | |||||
| put "/wiki/#{page.id}", | put "/wiki/#{page.id}", | ||||
| params: { title: 'TestPage', body: "x\ny", message: 'edit', base_revision_id: current_id }, | params: { title: 'TestPage', body: "x\ny", message: 'edit', base_revision_id: current_id }, | ||||
| headers: auth_headers(member) | headers: auth_headers(member) | ||||
| end.to change(WikiRevision, :count).by(1) | |||||
| end | |||||
| .to change(WikiRevision, :count).by(1) | |||||
| .and change(WikiVersion, :count).by(1) | |||||
| version = page.wiki_versions.order(:version_no).last | |||||
| expect(version).to have_attributes( | |||||
| event_type: 'update', | |||||
| title: 'TestPage', | |||||
| body: "x\ny", | |||||
| created_by_user_id: member.id | |||||
| ) | |||||
| expect(response).to have_http_status(:ok) | expect(response).to have_http_status(:ok) | ||||
| @@ -211,25 +263,60 @@ RSpec.describe 'Wiki API', type: :request do | |||||
| expect(page.body).to eq("x\ny") | expect(page.body).to eq("x\ny") | ||||
| expect(rev.base_revision_id).to eq(current_id) | expect(rev.base_revision_id).to eq(current_id) | ||||
| end | end | ||||
| it 'wiki body だけを変更しても tag version は作成しない' do | |||||
| linked_tag_name = TagName.create!(name: 'wiki_body_only_tag') | |||||
| linked_tag = Tag.create!(tag_name: linked_tag_name, category: :general) | |||||
| TagVersionRecorder.record!( | |||||
| tag: linked_tag, | |||||
| event_type: :create, | |||||
| created_by_user: member) | |||||
| linked_page = | |||||
| Wiki::Commit.create_content!( | |||||
| tag_name: linked_tag_name, | |||||
| body: 'before', | |||||
| created_by_user: member, | |||||
| message: 'init') | |||||
| current_id = linked_page.current_revision.id | |||||
| before_count = linked_tag.reload.tag_versions.count | |||||
| expect { | |||||
| put "/wiki/#{ linked_page.id }", | |||||
| params: { | |||||
| title: 'wiki_body_only_tag', | |||||
| body: 'after', | |||||
| message: 'edit', | |||||
| base_revision_id: current_id, | |||||
| }, | |||||
| headers: auth_headers(member) | |||||
| } | |||||
| .to change(WikiRevision, :count).by(1) | |||||
| .and change(WikiVersion, :count).by(1) | |||||
| expect(response).to have_http_status(:ok) | |||||
| expect(linked_tag.reload.tag_versions.count).to eq(before_count) | |||||
| end | |||||
| end | end | ||||
| # TODO: コンフリクト未実装のため,実装したらコメント外す. | |||||
| # context 'when conflict' do | |||||
| # 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 | |||||
| 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) | |||||
| 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 | |||||
| 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 | context 'when page not found' do | ||||
| it 'returns 404' do | it 'returns 404' do | ||||
| @@ -261,14 +348,17 @@ RSpec.describe 'Wiki API', type: :request do | |||||
| describe 'GET /wiki/search' do | describe 'GET /wiki/search' do | ||||
| before do | before do | ||||
| # 追加で検索ヒット用 | |||||
| TagName.create!(name: 'spec_wiki_title_2') | |||||
| WikiPage.create!(tag_name: TagName.find_by!(name: 'spec_wiki_title_2'), | |||||
| 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'), | |||||
| 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 | end | ||||
| it 'returns up to 20 pages filtered by title like' do | it 'returns up to 20 pages filtered by title like' do | ||||
| @@ -278,7 +368,9 @@ RSpec.describe 'Wiki API', type: :request do | |||||
| expect(json).to be_an(Array) | expect(json).to be_an(Array) | ||||
| titles = json.map { |p| p['title'] } | titles = json.map { |p| p['title'] } | ||||
| expect(titles).to include('spec_wiki_title', 'spec_wiki_title_2') | |||||
| expect(titles).to include('spec_wiki_title') | |||||
| expect(titles).to include('spec_wiki_title_2') | |||||
| expect(titles).not_to include('unrelated_title') | expect(titles).not_to include('unrelated_title') | ||||
| end | end | ||||
| @@ -329,7 +421,12 @@ RSpec.describe 'Wiki API', type: :request do | |||||
| it 'returns empty array when page has no revisions and filtered by id' do | it 'returns empty array when page has no revisions and filtered by id' do | ||||
| # 別ページを作って revision 無し | # 別ページを作って revision 無し | ||||
| tn2 = TagName.create!(name: 'spec_no_rev') | tn2 = TagName.create!(name: 'spec_no_rev') | ||||
| p2 = WikiPage.create!(tag_name: tn2, created_user: user, updated_user: user) | |||||
| # 異常データ: revision 無し WikiPage を直接作る | |||||
| p2 = WikiPage.create!( | |||||
| tag_name: tn2, | |||||
| body: 'init', | |||||
| created_user: user, | |||||
| updated_user: user) | |||||
| get "/wiki/changes?id=#{p2.id}" | get "/wiki/changes?id=#{p2.id}" | ||||
| expect(response).to have_http_status(:ok) | expect(response).to have_http_status(:ok) | ||||
| @@ -398,29 +495,68 @@ RSpec.describe 'Wiki API', type: :request do | |||||
| expect(json['older_revision_id']).to eq(rev_a.id) | expect(json['older_revision_id']).to eq(rev_a.id) | ||||
| expect(json['newer_revision_id']).to eq(page.current_revision.id) | expect(json['newer_revision_id']).to eq(page.current_revision.id) | ||||
| end | end | ||||
| end | |||||
| it 'returns 422 when "to" is redirect revision' do | |||||
| # 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) | |||||
| describe 'Wiki::Commit.redirect!' do | |||||
| it 'raises because redirect revisions are deprecated' do | |||||
| target_tag_name = TagName.create!(name: 'redirect_deprecated_target') | |||||
| target = | |||||
| Wiki::Commit.create_content!( | |||||
| tag_name: target_tag_name, | |||||
| body: 'target', | |||||
| created_by_user: user, | |||||
| message: 'init') | |||||
| 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 | |||||
| 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 | |||||
| get "/wiki/#{page.id}/diff?from=#{redirect_rev.id}&to=#{rev_b.id}" | |||||
| expect(response).to have_http_status(:unprocessable_entity) | |||||
| end | |||||
| it 'wiki title を変更すると対応する tag の version を作成する' do | |||||
| linked_tag_name = TagName.create!(name: 'wiki_linked_tag_for_version') | |||||
| linked_tag = Tag.create!(tag_name: linked_tag_name, category: :general) | |||||
| linked_page = | |||||
| Wiki::Commit.create_content!( | |||||
| tag_name: linked_tag_name, | |||||
| body: 'before', | |||||
| created_by_user: user, | |||||
| message: 'init') | |||||
| current_id = linked_page.current_revision.id | |||||
| expect { | |||||
| put "/wiki/#{ linked_page.id }", | |||||
| params: { | |||||
| title: 'wiki_linked_tag_for_version_renamed', | |||||
| body: 'after', | |||||
| message: 'rename', | |||||
| base_revision_id: current_id, | |||||
| }, | |||||
| headers: auth_headers(user) | |||||
| } | |||||
| .to change(WikiRevision, :count).by(1) | |||||
| .and change(WikiVersion, :count).by(1) | |||||
| .and change { linked_tag.reload.tag_versions.count }.by(2) | |||||
| expect(response).to have_http_status(:ok) | |||||
| linked_tag.reload | |||||
| expect(linked_tag.name).to eq('wiki_linked_tag_for_version_renamed') | |||||
| versions = linked_tag.tag_versions.order(:version_no) | |||||
| expect(versions.first.event_type).to eq('create') | |||||
| expect(versions.first.name).to eq('wiki_linked_tag_for_version') | |||||
| expect(versions.second.event_type).to eq('update') | |||||
| expect(versions.second.name).to eq('wiki_linked_tag_for_version_renamed') | |||||
| end | end | ||||
| end | end | ||||
| @@ -0,0 +1,62 @@ | |||||
| require 'rails_helper' | |||||
| RSpec.describe 'Wiki title collision', type: :request do | |||||
| let!(:user) { create_member_user! } | |||||
| def auth_headers user | |||||
| { 'X-Transfer-Code' => user.inheritance_code } | |||||
| end | |||||
| def create_wiki_page title:, body: | |||||
| Wiki::Commit.create_content!( | |||||
| tag_name: TagName.create!(name: title), | |||||
| body:, | |||||
| created_by_user: user, | |||||
| message: 'init') | |||||
| end | |||||
| it 'returns 422 when renaming wiki title to existing title' do | |||||
| source = create_wiki_page(title: 'wiki_collision_source', body: 'source body') | |||||
| create_wiki_page(title: 'wiki_collision_target', body: 'target body') | |||||
| source_revision_count = source.wiki_revisions.count | |||||
| source_version_count = source.wiki_versions.count | |||||
| old_title = source.title | |||||
| old_body = source.body | |||||
| put "/wiki/#{ source.id }", | |||||
| params: { | |||||
| title: 'wiki_collision_target', | |||||
| body: 'new body', | |||||
| message: 'rename collision', | |||||
| base_revision_id: source.current_revision.id, | |||||
| }, | |||||
| headers: auth_headers(user) | |||||
| expect(response).to have_http_status(:unprocessable_entity) | |||||
| source.reload | |||||
| expect(source.title).to eq(old_title) | |||||
| expect(source.body).to eq(old_body) | |||||
| expect(source.wiki_revisions.count).to eq(source_revision_count) | |||||
| expect(source.wiki_versions.count).to eq(source_version_count) | |||||
| end | |||||
| it 'returns 422 when creating wiki with existing title' do | |||||
| create_wiki_page(title: 'wiki_collision_create', body: 'already exists') | |||||
| expect { | |||||
| post '/wiki', | |||||
| params: { | |||||
| title: 'wiki_collision_create', | |||||
| body: 'new body', | |||||
| message: 'duplicate create', | |||||
| }, | |||||
| headers: auth_headers(user) | |||||
| } | |||||
| .not_to change(WikiPage, :count) | |||||
| expect(response).to have_http_status(:unprocessable_entity) | |||||
| end | |||||
| end | |||||
| @@ -0,0 +1,173 @@ | |||||
| require 'digest' | |||||
| require 'rails_helper' | |||||
| RSpec.describe Wiki::Commit do | |||||
| let(:user) { create_member_user! } | |||||
| def create_page title:, body: 'initial body' | |||||
| described_class.create_content!( | |||||
| tag_name: TagName.create!(name: title), | |||||
| body:, | |||||
| created_by_user: user, | |||||
| message: 'init') | |||||
| end | |||||
| describe '.create_content!' do | |||||
| it 'creates page, revision, and version with normalised body' do | |||||
| expect { | |||||
| described_class.create_content!( | |||||
| tag_name: TagName.create!(name: 'commit_integrity_create'), | |||||
| body: "a\r\nb\r\n\r\n", | |||||
| created_by_user: user, | |||||
| message: 'init') | |||||
| } | |||||
| .to change(WikiPage, :count).by(1) | |||||
| .and change(WikiRevision, :count).by(1) | |||||
| .and change(WikiVersion, :count).by(1) | |||||
| page = WikiPage.joins(:tag_name).find_by!(tag_names: { name: 'commit_integrity_create' }) | |||||
| revision = page.current_revision | |||||
| version = page.wiki_versions.order(:version_no).last | |||||
| expect(page.body).to eq("a\nb") | |||||
| expect(revision.lines_count).to eq(2) | |||||
| expect(version.body).to eq("a\nb") | |||||
| expect(version.reason).to eq('init') | |||||
| end | |||||
| it 'rejects body that becomes blank after normalisation' do | |||||
| tag_name = TagName.create!(name: 'commit_integrity_blank') | |||||
| expect { | |||||
| described_class.create_content!( | |||||
| tag_name:, | |||||
| body: "\r\n\r\n", | |||||
| created_by_user: user, | |||||
| message: 'blank') | |||||
| } | |||||
| .to raise_error(ActiveRecord::RecordInvalid) | |||||
| expect(WikiPage.where(tag_name:)).not_to exist | |||||
| end | |||||
| end | |||||
| describe '.content!' do | |||||
| it 'updates page body, revision, and version' do | |||||
| page = create_page(title: 'commit_integrity_update', body: 'before') | |||||
| current_id = page.current_revision.id | |||||
| expect { | |||||
| described_class.content!( | |||||
| page:, | |||||
| body: 'after', | |||||
| created_user: user, | |||||
| message: 'edit', | |||||
| base_revision_id: current_id) | |||||
| } | |||||
| .to change(WikiRevision, :count).by(1) | |||||
| .and change(WikiVersion, :count).by(1) | |||||
| page.reload | |||||
| version = page.wiki_versions.order(:version_no).last | |||||
| expect(page.body).to eq('after') | |||||
| expect(version.body).to eq('after') | |||||
| expect(version.reason).to eq('edit') | |||||
| end | |||||
| it 'does not record tag_version on body-only wiki update' do | |||||
| tag_name = TagName.create!(name: 'commit_integrity_linked_tag') | |||||
| tag = Tag.create!(tag_name:, category: :general) | |||||
| page = | |||||
| described_class.create_content!( | |||||
| tag_name:, | |||||
| body: 'before', | |||||
| created_by_user: user, | |||||
| message: 'init') | |||||
| TagVersionRecorder.record!( | |||||
| tag:, | |||||
| event_type: :create, | |||||
| created_by_user: user) | |||||
| before_count = tag.reload.tag_versions.count | |||||
| described_class.content!( | |||||
| page:, | |||||
| body: 'after', | |||||
| created_user: user, | |||||
| message: 'edit', | |||||
| base_revision_id: page.current_revision.id) | |||||
| expect(tag.reload.tag_versions.count).to eq(before_count) | |||||
| end | |||||
| it 'raises conflict and leaves page, revision, and version unchanged' do | |||||
| page = create_page(title: 'commit_integrity_conflict', body: 'first') | |||||
| stale_id = page.current_revision.id | |||||
| described_class.content!( | |||||
| page:, | |||||
| body: 'second', | |||||
| created_user: user, | |||||
| message: 'second', | |||||
| base_revision_id: stale_id) | |||||
| page.reload | |||||
| before_body = page.body | |||||
| before_revision_count = page.wiki_revisions.count | |||||
| before_version_count = page.wiki_versions.count | |||||
| expect { | |||||
| described_class.content!( | |||||
| page:, | |||||
| body: 'third', | |||||
| created_user: user, | |||||
| message: 'stale', | |||||
| base_revision_id: stale_id) | |||||
| } | |||||
| .to raise_error(Wiki::Commit::Conflict) | |||||
| page.reload | |||||
| expect(page.body).to eq(before_body) | |||||
| expect(page.wiki_revisions.count).to eq(before_revision_count) | |||||
| expect(page.wiki_versions.count).to eq(before_version_count) | |||||
| end | |||||
| it 'deduplicates duplicated missing wiki lines' do | |||||
| page = create_page(title: 'commit_integrity_dedup', body: 'before') | |||||
| duplicated = 'commit_integrity_duplicate_line' | |||||
| described_class.content!( | |||||
| page:, | |||||
| body: "#{ duplicated }\n#{ duplicated }", | |||||
| created_user: user, | |||||
| message: 'dedup', | |||||
| base_revision_id: page.current_revision.id) | |||||
| revision = page.reload.current_revision | |||||
| expect(WikiLine.where(body: duplicated).count).to eq(1) | |||||
| expect(revision.wiki_revision_lines.count).to eq(2) | |||||
| expect(revision.wiki_revision_lines.pluck(:wiki_line_id).uniq.size).to eq(1) | |||||
| end | |||||
| end | |||||
| describe '.redirect!' do | |||||
| it 'raises because redirect revisions are deprecated' do | |||||
| page = create_page(title: 'commit_integrity_redirect_source', body: 'source') | |||||
| target = create_page(title: 'commit_integrity_redirect_target', body: 'target') | |||||
| expect { | |||||
| described_class.redirect!( | |||||
| page:, | |||||
| redirect_page: target, | |||||
| created_user: user, | |||||
| message: 'redirect', | |||||
| base_revision_id: page.current_revision.id) | |||||
| } | |||||
| .to raise_error(RuntimeError, '廃止しました.') | |||||
| end | |||||
| end | |||||
| end | |||||
| @@ -0,0 +1,150 @@ | |||||
| require 'rails_helper' | |||||
| RSpec.describe Wiki::Commit do | |||||
| let(:user) { create_member_user! } | |||||
| def create_page(title: 'commit_spec_page', body: 'initial body') | |||||
| tag_name = TagName.create!(name: title) | |||||
| Wiki::Commit.create_content!( | |||||
| tag_name:, | |||||
| body:, | |||||
| created_by_user: user, | |||||
| message: 'init') | |||||
| end | |||||
| describe '.content!' do | |||||
| it 'stores normalised body in wiki_pages and wiki_versions' do | |||||
| page = create_page(title: 'commit_normalised_page') | |||||
| described_class.content!( | |||||
| page:, | |||||
| body: "a\r\nb\r\n\r\n", | |||||
| created_user: user, | |||||
| message: 'init' | |||||
| ) | |||||
| page.reload | |||||
| version = page.wiki_versions.order(:version_no).last | |||||
| expect(page.body).to eq("a\nb") | |||||
| expect(version.body).to eq("a\nb") | |||||
| expect(page.current_revision.lines_count).to eq(2) | |||||
| end | |||||
| it 'deduplicates duplicated missing wiki lines before upsert' do | |||||
| page = create_page(title: 'commit_duplicate_line_page') | |||||
| duplicated = 'commit_duplicate_line' | |||||
| described_class.content!( | |||||
| page:, | |||||
| body: "#{ duplicated }\n#{ duplicated }", | |||||
| created_user: user, | |||||
| message: 'init' | |||||
| ) | |||||
| page.reload | |||||
| expect(WikiLine.where(body: duplicated).count).to eq(1) | |||||
| expect(page.current_revision.lines_count).to eq(2) | |||||
| expect(page.current_revision.wiki_revision_lines.count).to eq(2) | |||||
| end | |||||
| it 'raises conflict when base_revision_id is stale' do | |||||
| page = create_page(title: 'commit_conflict_page') | |||||
| first = described_class.content!( | |||||
| page:, | |||||
| body: 'first', | |||||
| created_user: user, | |||||
| message: 'first' | |||||
| ) | |||||
| described_class.content!( | |||||
| page:, | |||||
| body: 'second', | |||||
| created_user: user, | |||||
| message: 'second', | |||||
| base_revision_id: first.id | |||||
| ) | |||||
| expect { | |||||
| described_class.content!( | |||||
| page:, | |||||
| body: 'third', | |||||
| created_user: user, | |||||
| message: 'third', | |||||
| base_revision_id: first.id | |||||
| ) | |||||
| }.to raise_error(Wiki::Commit::Conflict) | |||||
| end | |||||
| it 'does not record tag version when corresponding tag has no versions' do | |||||
| tag_name = TagName.create!(name: 'commit_linked_tag_without_versions') | |||||
| tag = Tag.create!(tag_name:, category: :general) | |||||
| page = | |||||
| described_class.create_content!( | |||||
| tag_name:, | |||||
| body: 'before', | |||||
| created_by_user: user, | |||||
| message: 'init') | |||||
| expect(tag.reload.tag_versions.count).to eq(0) | |||||
| current_revision_id = page.current_revision.id | |||||
| expect { | |||||
| described_class.content!( | |||||
| page:, | |||||
| body: 'after', | |||||
| created_user: user, | |||||
| message: 'edit', | |||||
| base_revision_id: current_revision_id) | |||||
| }.to change(WikiVersion, :count).by(1) | |||||
| expect(tag.reload.tag_versions.count).to eq(0) | |||||
| end | |||||
| it 'does not record tag version when corresponding tag has no versions' do | |||||
| tag_name = TagName.create!(name: 'commit_linked_tag_without_versions') | |||||
| tag = Tag.create!(tag_name:, category: :general) | |||||
| page = | |||||
| described_class.create_content!( | |||||
| tag_name:, | |||||
| body: 'before', | |||||
| created_by_user: user, | |||||
| message: 'init') | |||||
| current_revision_id = page.current_revision.id | |||||
| expect { | |||||
| described_class.content!( | |||||
| page:, | |||||
| body: 'after', | |||||
| created_user: user, | |||||
| message: 'edit', | |||||
| base_revision_id: current_revision_id) | |||||
| }.to change(WikiVersion, :count).by(1) | |||||
| expect(tag.reload.tag_versions.count).to eq(0) | |||||
| end | |||||
| end | |||||
| describe '.redirect!' do | |||||
| it 'raises because redirect revisions are deprecated' do | |||||
| page = create_page(title: 'commit_redirect_source') | |||||
| target = create_page(title: 'commit_redirect_target') | |||||
| expect { | |||||
| described_class.redirect!( | |||||
| page:, | |||||
| redirect_page: target, | |||||
| created_user: user, | |||||
| message: 'redirect' | |||||
| ) | |||||
| }.to raise_error(RuntimeError, '廃止しました.') | |||||
| end | |||||
| end | |||||
| end | |||||
| @@ -0,0 +1,99 @@ | |||||
| require 'rails_helper' | |||||
| RSpec.describe WikiVersionRecorder do | |||||
| let(:user) { create_member_user! } | |||||
| def create_page title:, body: 'body' | |||||
| Wiki::Commit.create_content!( | |||||
| tag_name: TagName.create!(name: title), | |||||
| body:, | |||||
| created_by_user: user, | |||||
| message: 'init') | |||||
| end | |||||
| describe '.record!' do | |||||
| it 'records title, body, reason, user, and version number' do | |||||
| page = create_page(title: 'wiki_version_recorder_basic', body: 'body') | |||||
| expect { | |||||
| described_class.record!( | |||||
| page:, | |||||
| event_type: :update, | |||||
| reason: 'manual reason', | |||||
| created_by_user: user) | |||||
| } | |||||
| .to change { page.reload.wiki_versions.count }.by(1) | |||||
| version = page.wiki_versions.order(:version_no).last | |||||
| expect(version).to have_attributes( | |||||
| version_no: 2, | |||||
| event_type: 'update', | |||||
| title: 'wiki_version_recorder_basic', | |||||
| body: 'body', | |||||
| reason: 'manual reason', | |||||
| created_by_user_id: user.id | |||||
| ) | |||||
| end | |||||
| it 'does not create duplicated update version for identical snapshot' do | |||||
| page = create_page(title: 'wiki_version_recorder_duplicate', body: 'body') | |||||
| described_class.record!( | |||||
| page:, | |||||
| event_type: :update, | |||||
| reason: nil, | |||||
| created_by_user: user) | |||||
| before_count = page.reload.wiki_versions.count | |||||
| described_class.record!( | |||||
| page:, | |||||
| event_type: :update, | |||||
| reason: nil, | |||||
| created_by_user: user) | |||||
| expect(page.reload.wiki_versions.count).to eq(before_count) | |||||
| end | |||||
| it 'creates update version when title changes' do | |||||
| page = create_page(title: 'wiki_version_recorder_title_before', body: 'body') | |||||
| page.tag_name.update!(name: 'wiki_version_recorder_title_after') | |||||
| expect { | |||||
| described_class.record!( | |||||
| page:, | |||||
| event_type: :update, | |||||
| reason: 'rename', | |||||
| created_by_user: user) | |||||
| } | |||||
| .to change { page.reload.wiki_versions.count }.by(1) | |||||
| version = page.wiki_versions.order(:version_no).last | |||||
| expect(version.title).to eq('wiki_version_recorder_title_after') | |||||
| expect(version.body).to eq('body') | |||||
| expect(version.reason).to eq('rename') | |||||
| end | |||||
| it 'creates update version when body changes' do | |||||
| page = create_page(title: 'wiki_version_recorder_body', body: 'before') | |||||
| page.update!(body: 'after') | |||||
| expect { | |||||
| described_class.record!( | |||||
| page:, | |||||
| event_type: :update, | |||||
| reason: 'body', | |||||
| created_by_user: user) | |||||
| } | |||||
| .to change { page.reload.wiki_versions.count }.by(1) | |||||
| version = page.wiki_versions.order(:version_no).last | |||||
| expect(version.title).to eq('wiki_version_recorder_body') | |||||
| expect(version.body).to eq('after') | |||||
| expect(version.reason).to eq('body') | |||||
| end | |||||
| end | |||||
| end | |||||