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

Merged
みてるぞ merged 6 commits from feature/317 into main 1 week ago
  1. +36
    -6
      backend/app/controllers/tags_controller.rb
  2. +31
    -14
      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. +56
    -37
      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. +6
    -4
      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. +206
    -70
      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) 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


+ 31
- 14
backend/app/controllers/wiki_pages_controller.rb View File

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

page =
Wiki::Commit.create_content!(
tag_name:,
body:,
created_by_user: current_user,
message:)


render json: WikiPageRepr.base(page), status: :created render json: WikiPageRepr.base(page), status: :created
else
render json: { errors: page.errors.full_messages },
status: :unprocessable_entity
end
rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotUnique
head :unprocessable_entity
end end


def update def update
@@ -113,12 +115,21 @@ 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
ApplicationRecord.transaction do
page.lock!

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 end


page.tag_name.update!(name: title) if title != old_title

message = params[:message].presence message = params[:message].presence
Wiki::Commit.content!(page:, Wiki::Commit.content!(page:,
body:, body:,
@@ -126,6 +137,12 @@ class WikiPagesController < ApplicationController
message:, message:,
base_revision_id:) 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
- 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 :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

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

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


+ 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

+ 56
- 37
backend/app/services/wiki/commit.rb View File

@@ -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,
missing_by_sha[sha] = {
sha256: sha,
body: lines[i], body: lines[i],
created_at: now, created_at: now,
updated_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




+ 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. # 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

+ 2
- 0
backend/spec/factories/wiki_pages.rb View File

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

+ 6
- 4
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 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!(
admin = create_admin_user!

Wiki::Commit.create_content!(
tag_name: source_tag_name, tag_name: source_tag_name,
created_user: create_admin_user!,
updated_user: create_admin_user!
)
body: 'source wiki body',
created_by_user: admin,
message: 'init')
end end


it 'rolls back the transaction' do 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.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

+ 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(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

+ 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

+ 206
- 70
backend/spec/requests/wiki_spec.rb View File

@@ -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,6 +43,7 @@ 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(
@@ -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)
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


Wiki::Commit.redirect!(page: page, redirect_page: target, created_user: user, message: 'redir')
redirect_rev = page.current_revision
expect(redirect_rev).to be_redirect
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)


get "/wiki/#{page.id}/diff?from=#{rev_a.id}&to=#{redirect_rev.id}"
expect(response).to have_http_status(:unprocessable_entity)
end
linked_page =
Wiki::Commit.create_content!(
tag_name: linked_tag_name,
body: 'before',
created_by_user: user,
message: 'init')


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)
current_id = linked_page.current_revision.id


Wiki::Commit.redirect!(page: page, redirect_page: target, created_user: user, message: 'redir2')
redirect_rev = page.current_revision
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)


get "/wiki/#{page.id}/diff?from=#{redirect_rev.id}&to=#{rev_b.id}"
expect(response).to have_http_status(:unprocessable_entity)
end
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

+ 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