Browse Source

Wiki のバージョン管理 (#317) (#333)

#317

#317

#317

#317

#317

#317

Co-authored-by: miteruzo <miteruzo@naver.com>
Reviewed-on: https://git.miteruzo.com/miteruzo/btrc-hub/pulls/333
feature/329
みてるぞ 1 week ago
parent
commit
0ff7fdf78a
23 changed files with 1648 additions and 155 deletions
  1. +36
    -6
      backend/app/controllers/tags_controller.rb
  2. +39
    -22
      backend/app/controllers/wiki_pages_controller.rb
  3. +2
    -1
      backend/app/models/ip_address.rb
  4. +3
    -5
      backend/app/models/wiki_page.rb
  5. +8
    -0
      backend/app/models/wiki_version.rb
  6. +59
    -40
      backend/app/services/wiki/commit.rb
  7. +21
    -0
      backend/app/services/wiki_version_recorder.rb
  8. +91
    -0
      backend/db/migrate/20260426120600_create_wiki_versions.rb
  9. +20
    -1
      backend/db/schema.rb
  10. +2
    -0
      backend/spec/factories/wiki_pages.rb
  11. +7
    -5
      backend/spec/models/tag_spec.rb
  12. +25
    -0
      backend/spec/requests/tag_versions_spec.rb
  13. +160
    -0
      backend/spec/requests/tag_wiki_history_integrity_spec.rb
  14. +178
    -0
      backend/spec/requests/tags_spec.rb
  15. +27
    -0
      backend/spec/requests/wiki_body_search_pending_spec.rb
  16. +42
    -0
      backend/spec/requests/wiki_conflict_spec.rb
  17. +196
    -0
      backend/spec/requests/wiki_history_integrity_spec.rb
  18. +37
    -0
      backend/spec/requests/wiki_restore_pending_spec.rb
  19. +211
    -75
      backend/spec/requests/wiki_spec.rb
  20. +62
    -0
      backend/spec/requests/wiki_title_collision_spec.rb
  21. +173
    -0
      backend/spec/services/wiki/commit_integrity_spec.rb
  22. +150
    -0
      backend/spec/services/wiki/commit_spec.rb
  23. +99
    -0
      backend/spec/services/wiki_version_recorder_spec.rb

+ 36
- 6
backend/app/controllers/tags_controller.rb View File

@@ -238,18 +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)

record_tag_version!(
tag,
event_type: :update,
created_by_user: current_user,
name_changed:,
wiki_page:)
end

render json: TagRepr.base(tag.reload)
@@ -272,10 +280,21 @@ 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
wiki_page = tag.tag_name.wiki_page if name_changed

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:,
wiki_page:)
end

render json: TagRepr.base(tag.reload)
@@ -297,12 +316,23 @@ 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, wiki_page: nil
if tag.nico?
NicoTagVersionRecorder.record!(tag:, event_type:, created_by_user:)
else
TagVersionRecorder.record!(tag:, event_type:, created_by_user:)
return
end

TagVersionRecorder.record!(tag:, event_type:, created_by_user:)

return unless name_changed

wiki_page ||= tag.tag_name.wiki_page
return unless wiki_page&.wiki_versions&.exists?

WikiVersionRecorder.record!(
page: wiki_page,
event_type: :update,
created_by_user:)
end

def update_aliases! tag, alias_names


+ 39
- 22
backend/app/controllers/wiki_pages_controller.rb View File

@@ -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:, 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,18 +115,33 @@ 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

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
end


+ 2
- 1
backend/app/models/ip_address.rb View File

@@ -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

+ 3
- 5
backend/app/models/wiki_page.rb View File

@@ -13,8 +13,11 @@ class WikiPage < ApplicationRecord
foreign_key: :redirect_page_id,
dependent: :nullify

has_many :wiki_versions

belongs_to :tag_name
validates :tag_name, presence: true
validates :body, presence: true

def title = tag_name.name

@@ -24,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


+ 8
- 0
backend/app/models/wiki_version.rb View File

@@ -0,0 +1,8 @@
class WikiVersion < ApplicationRecord
include VersionRecord

belongs_to :wiki_page

validates :title, presence: true
validates :body, presence: true
end

+ 59
- 40
backend/app/services/wiki/commit.rb View File

@@ -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)
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) }
@@ -37,10 +67,19 @@ 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: @page,
event_type: @page.wiki_versions.exists? ? :update : :create,
reason: message,
created_by_user: @created_user)

rev = WikiRevision.create!(
wiki_page: @page,
base_revision_id:,
@@ -54,65 +93,45 @@ 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!
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.gsub!("\r\n", "\n")
s.gsub!(/\r\n?/, "\n")
s.encode('UTF-8', invalid: :replace, undef: :replace, replace: '🖕')
s.gsub(/\n+$/, '')
end

def split_lines body
body.split("\n")
end
private

def split_lines(body) = body.split("\n")

def upsert_lines! lines, line_shas
now = Time.current

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



+ 21
- 0
backend/app/services/wiki_version_recorder.rb View File

@@ -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

+ 91
- 0
backend/db/migrate/20260426120600_create_wiki_versions.rb View File

@@ -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

+ 20
- 1
backend/db/schema.rb View File

@@ -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
- 0
backend/spec/factories/wiki_pages.rb View File

@@ -3,5 +3,7 @@ FactoryBot.define do
title { "TestPage" }
association :created_user, factory: :user
association :updated_user, factory: :user

body { ' ' }
end
end

+ 7
- 5
backend/spec/models/tag_spec.rb View File

@@ -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


+ 25
- 0
backend/spec/requests/tag_versions_spec.rb View File

@@ -214,5 +214,30 @@ RSpec.describe 'TagVersions API', type: :request do
expect(versions.size).to eq(1)
expect(versions.first['version_no']).to eq(2)
end

it 'does not create tag versions by wiki updates when tag has no versions yet' do
wiki_tag_name = TagName.create!(name: 'tag_versions_from_wiki')
wiki_tag = Tag.create!(tag_name: wiki_tag_name, category: :general)

wiki_page =
Wiki::Commit.create_content!(
tag_name: wiki_tag_name,
body: 'before',
created_by_user: member,
message: 'init')

Wiki::Commit.content!(
page: wiki_page,
body: 'after',
created_user: member,
message: 'edit',
base_revision_id: wiki_page.current_revision.id)

get '/tags/versions', params: { id: wiki_tag.id }

expect(response).to have_http_status(:ok)
expect(json.fetch('versions')).to eq([])
expect(json.fetch('count')).to eq(0)
end
end
end

+ 160
- 0
backend/spec/requests/tag_wiki_history_integrity_spec.rb View File

@@ -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

+ 178
- 0
backend/spec/requests/tags_spec.rb View File

@@ -471,6 +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

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

@@ -876,6 +924,136 @@ 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

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

+ 27
- 0
backend/spec/requests/wiki_body_search_pending_spec.rb View File

@@ -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

+ 42
- 0
backend/spec/requests/wiki_conflict_spec.rb View File

@@ -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

+ 196
- 0
backend/spec/requests/wiki_history_integrity_spec.rb View File

@@ -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

+ 37
- 0
backend/spec/requests/wiki_restore_pending_spec.rb View File

@@ -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

+ 211
- 75
backend/spec/requests/wiki_spec.rb View File

@@ -4,13 +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, 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
@@ -37,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

@@ -50,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
@@ -99,25 +107,34 @@ 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)

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
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_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
@@ -135,6 +152,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_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

@@ -146,17 +198,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
@@ -182,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
@@ -200,7 +241,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,25 +263,60 @@ 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 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

# 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
it 'returns 404' do
@@ -261,14 +348,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'),
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

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)

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

@@ -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
# 別ページを作って revision 無し
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}"
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['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)
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

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

+ 62
- 0
backend/spec/requests/wiki_title_collision_spec.rb View File

@@ -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

+ 173
- 0
backend/spec/services/wiki/commit_integrity_spec.rb View File

@@ -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

+ 150
- 0
backend/spec/services/wiki/commit_spec.rb View File

@@ -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

+ 99
- 0
backend/spec/services/wiki_version_recorder_spec.rb View File

@@ -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

Loading…
Cancel
Save