diff --git a/backend/spec/requests/post_imports_spec.rb b/backend/spec/requests/post_imports_spec.rb new file mode 100644 index 0000000..b3c6ae8 --- /dev/null +++ b/backend/spec/requests/post_imports_spec.rb @@ -0,0 +1,112 @@ +require 'rails_helper' + +RSpec.describe 'Post imports API', type: :request do + let(:member) { create(:user, :member) } + + before do + allow(Preview::UrlSafety).to receive(:validate) do |url| + [URI.parse(url), ['8.8.8.8']] + end + allow(PostMetadataFetcher).to receive(:fetch).and_return( + title: 'fetched title', + thumbnail_base: nil, + tags: '' + ) + end + + describe 'POST /posts/import/preview' do + it 'requires a member' do + sign_out + post '/posts/import/preview', params: { source: 'https://example.com/post' } + expect(response).to have_http_status(:unauthorized) + + sign_in_as(create(:user, :guest)) + post '/posts/import/preview', params: { source: 'https://example.com/post' } + expect(response).to have_http_status(:forbidden) + end + + it 'parses a URL list and returns preview rows' do + sign_in_as(member) + + post '/posts/import/preview', params: { + source: " https://example.com/one \r\n\r\nhttps://example.com/two" + } + + expect(response).to have_http_status(:ok) + expect(json.fetch('rows').map { _1.fetch('source_row') }).to eq([1, 3]) + expect(json.fetch('rows').map { _1.fetch('url') }).to eq([ + 'https://example.com/one', + 'https://example.com/two' + ]) + end + + it 'returns a safe 400 response for an invalid source' do + sign_in_as(member) + + post '/posts/import/preview', params: { source: '' } + + expect(response).to have_http_status(:bad_request) + expect(json.fetch('message')).to eq('URL を入力してください.') + end + end + + describe 'POST /posts/import/validate' do + it 'accepts camel-case row properties and returns their warnings' do + sign_in_as(member) + + post '/posts/import/validate', params: { + rows: [{ + sourceRow: '1', + url: 'https://example.com/post', + metadataUrl: 'https://example.com/post', + attributes: { title: 'manual title' }, + provenance: { url: 'manual', title: 'manual' }, + tagSources: { automatic: '', manual: '' }, + fieldWarnings: { title: ['old warning'] }, + baseWarnings: ['base warning'] + }], + changed_row: -1 + } + + expect(response).to have_http_status(:ok) + result = json.fetch('rows').first + expect(result.fetch('source_row')).to eq(1) + expect(result.fetch('tag_sources')).to eq('automatic' => '', 'manual' => '') + expect(result.fetch('field_warnings')).to eq('title' => ['old warning']) + expect(result.fetch('base_warnings')).to eq(['base warning']) + end + + it 'rejects a non-array rows value with 400' do + sign_in_as(member) + + post '/posts/import/validate', params: { rows: { sourceRow: 1 }, changed_row: -1 } + + expect(response).to have_http_status(:bad_request) + expect(json.fetch('message')).to eq('取込行の形式が不正です.') + end + end + + describe 'POST /posts/import' do + it 'returns a formal skipped result for an existing post' do + existing = create(:post, url: 'https://example.com/existing') + sign_in_as(member) + + post '/posts/import', params: { + rows: [{ + sourceRow: 1, + url: existing.url, + attributes: { title: 'ignored' }, + provenance: { url: 'manual', title: 'manual' }, + tagSources: { automatic: '', manual: '' } + }] + } + + expect(response).to have_http_status(:ok) + expect(json).to include('created' => 0, 'skipped' => 1, 'failed' => 0) + expect(json.fetch('rows').first).to include( + 'status' => 'skipped', + 'existing_post_id' => existing.id + ) + end + end +end diff --git a/backend/spec/services/post_import_previewer_spec.rb b/backend/spec/services/post_import_previewer_spec.rb new file mode 100644 index 0000000..fc6e16b --- /dev/null +++ b/backend/spec/services/post_import_previewer_spec.rb @@ -0,0 +1,187 @@ +require 'rails_helper' + +RSpec.describe PostImportPreviewer do + before do + allow(Preview::UrlSafety).to receive(:validate) do |url| + [URI.parse(url), ['8.8.8.8']] + end + end + + def row(source_row:, url:, attributes: { }, provenance: { }, **values) + { + source_row:, + url:, + attributes:, + provenance: + }.merge(values) + end + + describe '#preview_rows' do + it 'marks an existing post for skipping without fetching metadata' do + existing = create(:post, url: 'https://example.com/existing') + expect(PostMetadataFetcher).not_to receive(:fetch) + + result = described_class.new.preview_rows(rows: [ + row(source_row: 1, url: 'https://EXAMPLE.com/existing/') + ]).first + + expect(result).to include( + url: 'https://example.com/existing', + skip_reason: 'existing', + existing_post_id: existing.id, + validation_errors: {} + ) + expect(result.fetch(:field_warnings)).to eq({}) + end + + it 'reports list duplicates as URL errors instead of existing skips' do + create(:post, url: 'https://example.com/duplicate') + expect(PostMetadataFetcher).not_to receive(:fetch) + + results = described_class.new.preview_rows(rows: [ + row(source_row: 1, url: 'https://example.com/duplicate'), + row(source_row: 2, url: 'https://EXAMPLE.com/duplicate/') + ]) + + expect(results.map { _1[:validation_errors] }).to all( + include(url: ['URL が重複しています.']) + ) + expect(results).to all(include(skip_reason: nil, existing_post_id: nil)) + end + + it 'does not fetch metadata for an unsafe URL' do + allow(Preview::UrlSafety).to receive(:validate) + .and_raise(Preview::UrlSafety::UnsafeUrl, '安全でない接続先は使用できません.') + expect(PostMetadataFetcher).not_to receive(:fetch) + + result = described_class.new.preview_rows(rows: [ + row(source_row: 1, url: 'https://unsafe.example/post') + ]).first + + expect(result.fetch(:validation_errors)).to include( + url: ['安全でない接続先は使用できません.'] + ) + end + + it 'applies metadata to automatic fields and recognises metadata tags' do + Tag.create!(name: 'known-tag', category: :general) + allow(PostMetadataFetcher).to receive(:fetch).and_return( + title: 'metadata title', + thumbnail_base: 'https://example.com/thumb.jpg', + duration: 2_000, + tags: 'known-tag' + ) + + result = described_class.new.preview_rows(rows: [ + row(source_row: 1, url: 'https://example.com/new') + ]).first + + expect(result.fetch(:attributes)).to include( + 'title' => 'metadata title', + 'thumbnail_base' => 'https://example.com/thumb.jpg', + 'duration' => 2_000, + 'tags' => 'known-tag' + ) + expect(result.fetch(:field_warnings)).not_to have_key('tags') + expect(PostMetadataFetcher).to have_received(:fetch).once + end + + it 'preserves manual empty values when metadata is available' do + allow(PostMetadataFetcher).to receive(:fetch).and_return( + title: 'metadata title', + tags: 'metadata-tag' + ) + + result = described_class.new.preview_rows(rows: [ + row( + source_row: 1, + url: 'https://example.com/manual', + attributes: { title: '', tags: '' }, + provenance: { title: 'manual', tags: 'manual' }, + tag_sources: { automatic: 'old-tag', manual: '' } + ) + ]).first + + expect(result.fetch(:attributes)).to include('title' => '', 'tags' => '') + expect(result.fetch(:tag_sources)).to include('manual' => '') + end + + it 'clears warnings derived from an old metadata URL' do + allow(PostMetadataFetcher).to receive(:fetch).and_return( + title: 'new title', + thumbnail_base: 'https://example.com/new-thumb.jpg', + tags: '' + ) + + result = described_class.new.preview_rows(rows: [ + row( + source_row: 1, + url: 'https://example.com/new-url', + attributes: { title: 'old title', tags: 'old-tag' }, + provenance: { title: 'automatic', tags: 'automatic' }, + tag_sources: { automatic: 'old-tag', manual: '' }, + metadata_url: 'https://example.com/old-url', + field_warnings: { tags: ['old tag warning'], url: ['old fetch warning'] }, + base_warnings: ['old base warning'] + ) + ]).first + + expect(result.fetch(:attributes)).to include('title' => 'new title', 'tags' => '') + expect(result.fetch(:base_warnings)).to eq([]) + expect(result.fetch(:field_warnings).values.flatten) + .not_to include('old tag warning', 'old fetch warning') + end + + it 'isolates an unexpected metadata failure to the affected URL' do + allow(PostMetadataFetcher).to receive(:fetch) do |url| + raise 'fetch failure' if url.include?('failure') + + { title: 'successful title', tags: '' } + end + allow(Rails.logger).to receive(:error) + + results = described_class.new.preview_rows(rows: [ + row(source_row: 1, url: 'https://example.com/failure'), + row(source_row: 2, url: 'https://example.com/success') + ]) + + expect(results[0].fetch(:field_warnings)).to include( + 'url' => [described_class::METADATA_FETCH_WARNING] + ) + expect(results[1].fetch(:attributes)).to include('title' => 'successful title') + end + + it 'turns an unsafe URL detected during metadata fetching into a URL error' do + allow(PostMetadataFetcher).to receive(:fetch) + .and_raise(Preview::UrlSafety::UnsafeUrl, '安全でない接続先です.') + allow(Rails.logger).to receive(:info) + + result = described_class.new.preview_rows(rows: [ + row(source_row: 1, url: 'https://example.com/redirects-to-private') + ]).first + + expect(result.fetch(:validation_errors)).to include( + url: ['安全でない接続先です.'] + ) + end + + it 'validates parent IDs from the preloaded set' do + parent = create(:post) + + results = described_class.new.preview_rows( + rows: [ + row(source_row: 1, url: 'https://example.com/valid-parent', + attributes: { parent_post_ids: parent.id.to_s }), + row(source_row: 2, url: 'https://example.com/missing-parent', + attributes: { parent_post_ids: '999999' }) + ], + fetch_metadata: false + ) + + expect(results[0].fetch(:validation_errors)).not_to have_key(:parent_post_ids) + expect(results[1].fetch(:validation_errors)).to include( + parent_post_ids: ['存在しない親投稿 Id. があります.'] + ) + end + end +end diff --git a/backend/spec/services/post_import_row_normaliser_spec.rb b/backend/spec/services/post_import_row_normaliser_spec.rb new file mode 100644 index 0000000..ec1a41c --- /dev/null +++ b/backend/spec/services/post_import_row_normaliser_spec.rb @@ -0,0 +1,77 @@ +require 'rails_helper' + +RSpec.describe PostImportRowNormaliser do + def valid_row(overrides = { }) + { + sourceRow: '1', + url: 'https://example.com/post', + metadataUrl: 'https://example.com/post', + attributes: { title: 'title', duration: 1_000 }, + provenance: { url: 'manual', title: 'automatic' }, + tagSources: { automatic: 'tag', manual: '' } + }.deep_merge(overrides) + end + + describe '.normalise!' do + it 'normalises aliases and source rows into a permitted plain hash' do + row = ActionController::Parameters.new(valid_row) + + expect(described_class.normalise!([row])).to eq([ + { + 'source_row' => 1, + 'url' => 'https://example.com/post', + 'metadata_url' => 'https://example.com/post', + 'attributes' => { 'title' => 'title', 'duration' => 1_000 }, + 'provenance' => { 'url' => 'manual', 'title' => 'automatic' }, + 'tag_sources' => { 'automatic' => 'tag', 'manual' => '' } + } + ]) + end + + it 'normalises source rows before checking duplicates' do + rows = [valid_row, valid_row(sourceRow: 1, url: 'https://example.com/other')] + + expect { described_class.normalise!(rows) } + .to raise_error(ArgumentError, '元行番号が重複しています.') + end + + it 'rejects non-array batches and non-hash rows' do + expect { described_class.normalise!({}) } + .to raise_error(ArgumentError, '取込行の形式が不正です.') + expect { described_class.normalise!(['row']) } + .to raise_error(ArgumentError, '取込行の形式が不正です.') + end + + it 'rejects unknown attributes and invalid field types' do + expect { described_class.normalise!([valid_row(attributes: { unknown: 'x' })]) } + .to raise_error(ArgumentError, '取込項目が不正です.') + expect { described_class.normalise!([valid_row(attributes: { title: [] })]) } + .to raise_error(ArgumentError, '取込項目の型が不正です.') + expect { described_class.normalise!([valid_row(attributes: { duration: false })]) } + .to raise_error(ArgumentError, '取込項目の型が不正です.') + end + + it 'rejects unknown provenance and tag-source values' do + expect { described_class.normalise!([valid_row(provenance: { title: 'mapped' })]) } + .to raise_error(ArgumentError, '値の由来が不正です.') + expect { described_class.normalise!([valid_row(tagSources: { mapped: 'tag' })]) } + .to raise_error(ArgumentError, 'タグ由来の形式が不正です.') + end + + it 'accepts warning fields only at the validation boundary' do + row = valid_row.merge( + fieldWarnings: { title: ['取得できませんでした.'] }, + baseWarnings: ['確認してください.'] + ) + + without_warnings = described_class.normalise!([row]).first + with_warnings = described_class.normalise!([row], allow_warning_fields: true).first + + expect(without_warnings).not_to include('field_warnings', 'base_warnings') + expect(with_warnings).to include( + 'field_warnings' => { 'title' => ['取得できませんでした.'] }, + 'base_warnings' => ['確認してください.'] + ) + end + end +end diff --git a/backend/spec/services/post_import_runner_spec.rb b/backend/spec/services/post_import_runner_spec.rb new file mode 100644 index 0000000..235dabf --- /dev/null +++ b/backend/spec/services/post_import_runner_spec.rb @@ -0,0 +1,133 @@ +require 'rails_helper' + +RSpec.describe PostImportRunner do + let(:actor) { create(:user, :member) } + + def row(source_row: 1, url: 'https://example.com/post') + { + sourceRow: source_row, + url:, + attributes: { title: 'title', tags: '' }, + provenance: { url: 'manual', title: 'manual', tags: 'manual' }, + tagSources: { automatic: '', manual: '' } + } + end + + def preview(source_row: 1, errors: { }, skip_reason: nil, existing_post_id: nil) + { + source_row:, + attributes: { 'title' => 'title', 'tags' => '' }, + validation_errors: errors, + skip_reason:, + existing_post_id: + } + end + + it 'previews the whole batch once before processing individual rows' do + rows = [row, row(source_row: 2, url: 'https://example.com/two')] + previewer = instance_double(PostImportPreviewer) + allow(PostImportPreviewer).to receive(:new).and_return(previewer) + expect(previewer).to receive(:preview_rows) + .with( + rows: satisfy { _1.map { |row_value| row_value['source_row'] } == [1, 2] }, + fetch_metadata: false + ) + .and_return([preview, preview(source_row: 2)]) + allow(PostCreator).to receive(:new).and_return( + instance_double(PostCreator, create!: create(:post)) + ) + + result = described_class.new(actor:, rows:).run + + expect(result).to include(created: 2, skipped: 0, failed: 0) + end + + it 'treats validation errors as failures before an existing skip' do + existing = create(:post) + previewer = instance_double(PostImportPreviewer) + allow(PostImportPreviewer).to receive(:new).and_return(previewer) + allow(previewer).to receive(:preview_rows).and_return([ + preview(errors: { url: ['URL が重複しています.'] }, + skip_reason: 'existing', existing_post_id: existing.id) + ]) + expect(PostCreator).not_to receive(:new) + + result = described_class.new(actor:, rows: [row]).run.fetch(:rows).first + + expect(result).to include( + status: 'failed', + errors: { url: ['URL が重複しています.'] }, + recoverable: true + ) + end + + it 'returns the existing post ID for skipped rows' do + existing = create(:post) + previewer = instance_double(PostImportPreviewer) + allow(PostImportPreviewer).to receive(:new).and_return(previewer) + allow(previewer).to receive(:preview_rows).and_return([ + preview(skip_reason: 'existing', existing_post_id: existing.id) + ]) + + result = described_class.new(actor:, rows: [row]).run.fetch(:rows).first + + expect(result).to eq( + source_row: 1, + status: 'skipped', + existing_post_id: existing.id + ) + end + + it 'converts a URL uniqueness validation race into a skip' do + existing = create(:post, url: 'https://example.com/race') + invalid = Post.new(url: existing.url) + invalid.errors.add(:url, :taken) + previewer = instance_double(PostImportPreviewer) + allow(PostImportPreviewer).to receive(:new).and_return(previewer) + allow(previewer).to receive(:preview_rows).and_return([preview]) + creator = instance_double(PostCreator) + allow(PostCreator).to receive(:new).and_return(creator) + allow(creator).to receive(:create!).and_raise(ActiveRecord::RecordInvalid.new(invalid)) + + result = described_class.new( + actor:, + rows: [row(url: 'https://EXAMPLE.com/race/')] + ).run.fetch(:rows).first + + expect(result).to include(status: 'skipped', existing_post_id: existing.id) + end + + it 're-raises RecordNotUnique errors unrelated to the posts URL index' do + previewer = instance_double(PostImportPreviewer) + allow(PostImportPreviewer).to receive(:new).and_return(previewer) + allow(previewer).to receive(:preview_rows).and_return([preview]) + creator = instance_double(PostCreator) + allow(PostCreator).to receive(:new).and_return(creator) + allow(creator).to receive(:create!) + .and_raise(ActiveRecord::RecordNotUnique, 'other_unique_index') + + expect { + described_class.new(actor:, rows: [row]).run + }.to raise_error(ActiveRecord::RecordNotUnique) + end + + it 'converts a posts URL index race into a skip' do + existing = create(:post, url: 'https://example.com/index-race') + previewer = instance_double(PostImportPreviewer) + allow(PostImportPreviewer).to receive(:new).and_return(previewer) + allow(previewer).to receive(:preview_rows).and_return([preview]) + creator = instance_double(PostCreator) + allow(PostCreator).to receive(:new).and_return(creator) + allow(creator).to receive(:create!).and_raise( + ActiveRecord::RecordNotUnique, + 'duplicate key index_posts_on_url' + ) + + result = described_class.new( + actor:, + rows: [row(url: 'https://EXAMPLE.com/index-race/')] + ).run.fetch(:rows).first + + expect(result).to include(status: 'skipped', existing_post_id: existing.id) + end +end diff --git a/backend/spec/services/post_import_url_list_parser_spec.rb b/backend/spec/services/post_import_url_list_parser_spec.rb new file mode 100644 index 0000000..0704abc --- /dev/null +++ b/backend/spec/services/post_import_url_list_parser_spec.rb @@ -0,0 +1,40 @@ +require 'rails_helper' + +RSpec.describe PostImportUrlListParser do + describe '.parse' do + it 'trims URLs, ignores blank lines, and preserves source line numbers' do + source = " https://example.com/one \r\n\r\nhttps://example.com/two\n" + + expect(described_class.parse(source)).to eq([ + { source_row: 1, url: 'https://example.com/one' }, + { source_row: 3, url: 'https://example.com/two' } + ]) + end + + it 'rejects an empty URL list' do + expect { described_class.parse(" \n\r\n") } + .to raise_error(ArgumentError, 'URL を入力してください.') + end + + it 'rejects more than 100 non-empty rows' do + source = 101.times.map { |index| "https://example.com/#{ index }" }.join("\n") + + expect { described_class.parse(source) } + .to raise_error(ArgumentError, '取込件数は 100 件までです.') + end + + it 'includes the original line number in an oversized URL error' do + source = "\n#{ 'a' * (described_class::MAX_URL_BYTES + 1) }" + + expect { described_class.parse(source) } + .to raise_error(ArgumentError, '2 行目: URL が長すぎます.') + end + + it 'rejects an oversized request before parsing rows' do + source = 'a' * (described_class::MAX_BYTES + 1) + + expect { described_class.parse(source) } + .to raise_error(ArgumentError, '入力が大きすぎます.') + end + end +end diff --git a/backend/spec/services/post_metadata_fetcher_spec.rb b/backend/spec/services/post_metadata_fetcher_spec.rb new file mode 100644 index 0000000..38e5d53 --- /dev/null +++ b/backend/spec/services/post_metadata_fetcher_spec.rb @@ -0,0 +1,64 @@ +require 'rails_helper' + +RSpec.describe PostMetadataFetcher do + Response = Struct.new(:body) + + def fetch_with_published_time(value, url: 'https://example.com/video') + html = <<~HTML + + + + HTML + uri = URI.parse(url) + allow(Preview::UrlSafety).to receive(:validate).with(url).and_return([uri, ['8.8.8.8']]) + allow(Preview::HttpFetcher).to receive(:fetch).and_return(Response.new(html)) + allow(Preview::HtmlMetadataExtractor).to receive(:extract) + .and_return(title: 'title', image_url: nil) + + described_class.fetch(url) + end + + it 'builds ranges explicitly for year, month, and day precision' do + year = fetch_with_published_time('2024') + month = fetch_with_published_time('2024-02') + day = fetch_with_published_time('2024-02-03') + + expect(year).to include( + original_created_from: Time.zone.local(2024, 1, 1).iso8601, + original_created_before: Time.zone.local(2025, 1, 1).iso8601 + ) + expect(month).to include( + original_created_from: Time.zone.local(2024, 2, 1).iso8601, + original_created_before: Time.zone.local(2024, 3, 1).iso8601 + ) + expect(day).to include( + original_created_from: Time.zone.local(2024, 2, 3).iso8601, + original_created_before: Time.zone.local(2024, 2, 4).iso8601 + ) + end + + it 'preserves an input offset and fractional-second precision' do + result = fetch_with_published_time('2024-02-03T12:34:56.123+02:30') + + expect(Time.iso8601(result.fetch(:original_created_from))) + .to eq(Time.iso8601('2024-02-03T12:34:56.123+02:30')) + expect( + Time.iso8601(result.fetch(:original_created_before)) - + Time.iso8601(result.fetch(:original_created_from)) + ).to eq(0.001) + expect(result.fetch(:original_created_from)).to include('.123000000') + end + + it 'adds platform tags for known video URLs' do + result = fetch_with_published_time('2024', url: 'https://youtu.be/abc123') + + expect(result.fetch(:tags)).to eq('動画 YouTube') + end + + it 'returns nil dates for an invalid timestamp' do + result = fetch_with_published_time('not-a-time') + + expect(result.fetch(:original_created_from)).to be_nil + expect(result.fetch(:original_created_before)).to be_nil + end +end diff --git a/backend/spec/services/post_url_normaliser_spec.rb b/backend/spec/services/post_url_normaliser_spec.rb new file mode 100644 index 0000000..dafcdb2 --- /dev/null +++ b/backend/spec/services/post_url_normaliser_spec.rb @@ -0,0 +1,27 @@ +require 'rails_helper' + +RSpec.describe PostUrlNormaliser do + before do + PostUrlSanitisationRule.unscoped.delete_all + end + + describe '.normalise' do + it 'normalises the host and trailing slash before applying sanitisation rules' do + PostUrlSanitisationRule.create!( + priority: 10, + source_pattern: '\\Ahttps://example\\.com/source\\z', + replacement: 'https://example.com/canonical' + ) + + result = described_class.normalise(' https://EXAMPLE.com/source/ ') + + expect(result).to eq('https://example.com/canonical') + end + + it 'returns nil for unsupported or malformed URLs' do + expect(described_class.normalise('ftp://example.com/file')).to be_nil + expect(described_class.normalise('https://[')).to be_nil + expect(described_class.normalise('https:/path')).to be_nil + end + end +end diff --git a/frontend/src/components/TopNav.test.tsx b/frontend/src/components/TopNav.test.tsx new file mode 100644 index 0000000..4d41dcd --- /dev/null +++ b/frontend/src/components/TopNav.test.tsx @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest' + +import { menuOutline } from '@/components/TopNav' +import { buildUser } from '@/test/factories' + +const submenuItem = (role: 'guest' | 'member' | 'admin', section: string, item: string) => { + const menu = menuOutline ({ + user: buildUser ({ role }), + wikiId: section === 'Wiki' ? 10 : null, + pathName: section === 'Wiki' ? '/wiki/page' : '/posts' }) + return menu.find (_1 => _1.name === section)?.subMenu.find (_1 => _1.name === item) +} + +describe ('menuOutline', () => { + it ('uses content-edit permission for post, material, and Wiki actions', () => { + for (const role of ['member', 'admin'] as const) + { + expect (submenuItem (role, '広場', '追加')?.visible).toBe (true) + expect (submenuItem (role, '広場', '取込')?.visible).toBe (true) + expect (submenuItem (role, '素材', '追加')?.visible).toBe (true) + expect (submenuItem (role, 'Wiki', '新規')?.visible).toBe (true) + expect (submenuItem (role, 'Wiki', '編輯')?.visible).toBe (true) + } + + expect (submenuItem ('guest', '広場', '追加')?.visible).toBe (false) + expect (submenuItem ('guest', '広場', '取込')?.visible).toBe (false) + expect (submenuItem ('guest', '素材', '追加')?.visible).toBe (false) + expect (submenuItem ('guest', 'Wiki', '新規')?.visible).toBe (false) + expect (submenuItem ('guest', 'Wiki', '編輯')?.visible).toBe (false) + }) + + it ('keeps material suppression admin-only', () => { + expect (submenuItem ('member', '素材', '抑止')?.visible).toBe (false) + expect (submenuItem ('admin', '素材', '抑止')?.visible).toBe (true) + }) +}) diff --git a/frontend/src/components/dialogues/DialogueProvider.test.tsx b/frontend/src/components/dialogues/DialogueProvider.test.tsx new file mode 100644 index 0000000..d2bc4f8 --- /dev/null +++ b/frontend/src/components/dialogues/DialogueProvider.test.tsx @@ -0,0 +1,114 @@ +import { useCallback, useEffect } from 'react' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' + +import DialogueProvider from '@/components/dialogues/DialogueProvider' +import useDialogue from '@/lib/dialogues/useDialogue' + +import type { DialogueFormControls } from '@/lib/dialogues/useDialogue' + +const FormBody = ( + { controls, + onSelect }: { controls: DialogueFormControls + onSelect: () => Promise | boolean }, +) => { + useEffect (() => { + controls.setActions ([{ + label: '左操作', + placement: 'start', + onSelect }, { + label: '保存', + onSelect: () => true }]) + }, [controls, onSelect]) + + return
長いフォーム本文
+} + +const NestedConfirmFormBody = ({ controls }: { controls: DialogueFormControls }) => { + const reset = useCallback (async () => { + await controls.confirm ({ + title: '変更をリセットしますか?', + confirmText: 'リセット' }) + return false + }, [controls]) + + return +} + +describe ('DialogueProvider', () => { + it ('keeps ordinary dialogues in FIFO order', async () => { + const Launcher = () => { + const dialogue = useDialogue () + return ( + ) + } + + render () + fireEvent.click (screen.getByRole ('button', { name: '開く' })) + + expect (screen.getByText ('一件目')).toBeInTheDocument () + expect (screen.queryByText ('二件目')).not.toBeInTheDocument () + fireEvent.click (screen.getByRole ('button', { name: '確定' })) + expect (await screen.findByText ('二件目')).toBeInTheDocument () + }) + + it ('keeps a large form open when an action returns false', async () => { + const action = vi.fn ().mockResolvedValue (false) + const Launcher = () => { + const dialogue = useDialogue () + return ( + ) + } + + render () + fireEvent.click (screen.getByRole ('button', { name: '開く' })) + + const dialogue = screen.getByRole ('dialog') + expect (dialogue).toHaveClass ('max-h-[calc(100dvh-1rem)]', 'flex-col', 'max-w-3xl') + await waitFor (() => expect (screen.getByRole ('button', { name: '左操作' })) + .toBeInTheDocument ()) + expect (screen.getByRole ('button', { name: '左操作' })).toHaveClass ('w-full', 'sm:w-auto') + fireEvent.click (screen.getByRole ('button', { name: '左操作' })) + + await waitFor (() => expect (action).toHaveBeenCalledTimes (1)) + expect (screen.getByText ('投稿を編輯')).toBeInTheDocument () + }) + + it ('opens a nested confirmation over a form and returns to the same form', async () => { + const Launcher = () => { + const dialogue = useDialogue () + return ( + ) + } + + render () + fireEvent.click (screen.getByRole ('button', { name: '開く' })) + const action = await screen.findByRole ('button', { name: '左操作' }) + fireEvent.click (action) + + expect (await screen.findByText ('変更をリセットしますか?')).toBeInTheDocument () + fireEvent.click (screen.getByRole ('button', { name: 'リセット' })) + await waitFor (() => { + expect (screen.queryByText ('変更をリセットしますか?')).not.toBeInTheDocument () + }) + expect (screen.getByText ('投稿を編輯')).toBeInTheDocument () + }) +}) diff --git a/frontend/src/components/posts/import/PostImportRowForm.test.tsx b/frontend/src/components/posts/import/PostImportRowForm.test.tsx new file mode 100644 index 0000000..26b3c79 --- /dev/null +++ b/frontend/src/components/posts/import/PostImportRowForm.test.tsx @@ -0,0 +1,91 @@ +import { act, render, screen, waitFor } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' + +import PostImportRowForm from '@/components/posts/import/PostImportRowForm' +import { buildPostImportRow } from '@/test/postImportFactories' + +import type { DialogueFormAction, DialogueFormControls } from '@/lib/dialogues/useDialogue' + +describe ('PostImportRowForm', () => { + it ('resets only the draft, then saves with resetRequested', async () => { + const row = buildPostImportRow () + row.attributes.title = 'manual title' + row.provenance.title = 'manual' + const actions: DialogueFormAction[][] = [] + const controls: DialogueFormControls = { + close: vi.fn (), + confirm: vi.fn ().mockResolvedValue (true), + setActions: next => actions.push (next) } + const invalidRow = buildPostImportRow ({ + validationErrors: { title: ['タイトルを確認してください.'] } }) + const onSave = vi.fn ().mockResolvedValue ({ saved: false, row: invalidRow }) + + render () + const titleInput = screen.getByDisplayValue ('manual title') + + await waitFor (() => expect (actions.at (-1)?.length).toBe (2)) + const reset = actions.at (-1)?.find (_1 => _1.label === '変更をリセット') + expect (reset).toMatchObject ({ placement: 'start', variant: 'danger', disabled: false }) + await act (async () => { + await reset?.onSelect () + }) + + expect (controls.confirm).toHaveBeenCalled () + expect (titleInput).toHaveValue ('') + expect (onSave).not.toHaveBeenCalled () + + const save = actions.at (-1)?.find (_1 => _1.label === '編輯内容を保存') + await act (async () => { + await save?.onSelect () + }) + + expect (onSave).toHaveBeenCalledWith ({ + draft: expect.objectContaining ({ title: '' }), + resetRequested: true }) + expect (screen.getByText ('タイトルを確認してください.')).toBeInTheDocument () + }) + + it ('does not reset the draft when confirmation is cancelled', async () => { + const row = buildPostImportRow ({ attributes: { title: 'manual title' } }) + let actions: DialogueFormAction[] = [] + const controls: DialogueFormControls = { + close: vi.fn (), + confirm: vi.fn ().mockResolvedValue (false), + setActions: next => { + actions = next + } } + + render ( + ) + await waitFor (() => expect (actions.length).toBe (2)) + + await act (async () => { + await actions.find (_1 => _1.label === '変更をリセット')?.onSelect () + }) + + expect (screen.getByDisplayValue ('manual title')).toBeInTheDocument () + }) + + it ('marks edited fields and areas invalid from field errors', () => { + const row = buildPostImportRow ({ + validationErrors: { url: ['URL error'], tags: ['tag error'] }, + importErrors: { duration: ['duration error'] }, + fieldWarnings: { title: ['title warning'] } }) + + render ( + ) + + expect (screen.getByText ('URL error')).toBeInTheDocument () + expect (screen.getByText ('tag error')).toBeInTheDocument () + expect (screen.getByText ('duration error')).toBeInTheDocument () + expect (screen.getByText ('title warning')).toBeInTheDocument () + expect (screen.getAllByRole ('textbox').filter ( + _1 => _1.getAttribute ('aria-invalid') === 'true')).toHaveLength (3) + }) +}) diff --git a/frontend/src/components/posts/import/postImportRowStatus.test.ts b/frontend/src/components/posts/import/postImportRowStatus.test.ts new file mode 100644 index 0000000..30e78fa --- /dev/null +++ b/frontend/src/components/posts/import/postImportRowStatus.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest' + +import { displayPostImportStatus } from '@/components/posts/import/postImportRowStatus' +import { buildPostImportRow } from '@/test/postImportFactories' + +describe ('displayPostImportStatus', () => { + it ('shows only ready, warning, and skipped states', () => { + expect (displayPostImportStatus (buildPostImportRow ())).toBe ('ready') + expect (displayPostImportStatus (buildPostImportRow ({ + status: 'warning', + fieldWarnings: { title: ['warning'] } }))).toBe ('warning') + expect (displayPostImportStatus (buildPostImportRow ({ + skipReason: 'existing', + existingPostId: 2 }))).toBe ('skipped') + }) + + it ('does not expose validation, failure, or created states as badges', () => { + expect (displayPostImportStatus (buildPostImportRow ({ + status: 'error', + validationErrors: { title: ['invalid'] } }))).toBeNull () + expect (displayPostImportStatus (buildPostImportRow ({ + importStatus: 'failed' }))).toBeNull () + expect (displayPostImportStatus (buildPostImportRow ({ + importStatus: 'created', + createdPostId: 3 }))).toBeNull () + }) +}) diff --git a/frontend/src/lib/postImportRows.test.ts b/frontend/src/lib/postImportRows.test.ts new file mode 100644 index 0000000..b1aeb01 --- /dev/null +++ b/frontend/src/lib/postImportRows.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, it } from 'vitest' + +import { creatableImportRows, + initialisePreviewRows, + mergeImportResults, + mergeValidatedImportRows, + processableImportRows, + resultSummaryCounts, + retryImportRow, + reviewSummaryCounts } from '@/lib/postImportSession' +import { buildPostImportRow } from '@/test/postImportFactories' + +describe ('post import row state', () => { + it ('separates processable existing rows from creatable rows', () => { + const ready = buildPostImportRow ({ sourceRow: 1 }) + const existing = buildPostImportRow ({ + sourceRow: 2, + skipReason: 'existing', + existingPostId: 20 }) + const invalid = buildPostImportRow ({ + sourceRow: 3, + status: 'error', + validationErrors: { url: ['invalid'] } }) + const created = buildPostImportRow ({ + sourceRow: 4, + importStatus: 'created', + createdPostId: 40 }) + const rows = [ready, existing, invalid, created] + + expect (processableImportRows (rows)).toEqual ([ready, existing]) + expect (creatableImportRows (rows)).toEqual ([ready]) + expect (reviewSummaryCounts (rows)).toEqual ({ + total: 4, + submittable: 1, + skipPlanned: 1 }) + }) + + it ('preserves terminal rows while merging validation results', () => { + const created = buildPostImportRow ({ + sourceRow: 1, + importStatus: 'created', + createdPostId: 10, + attributes: { title: 'created title' } }) + const pending = buildPostImportRow ({ + sourceRow: 2, + fieldWarnings: { title: ['old warning'] }, + provenance: { title: 'manual' }, + attributes: { title: 'manual title' } }) + const validated = [ + buildPostImportRow ({ sourceRow: 1, attributes: { title: 'changed' } }), + buildPostImportRow ({ + sourceRow: 2, + fieldWarnings: { title: ['fetch warning'], tags: ['tag warning'] }, + provenance: { title: 'manual' }, + attributes: { title: 'manual title' } })] + + const result = mergeValidatedImportRows ([created, pending], validated) + + expect (result[0]).toBe (created) + expect (result[1]?.fieldWarnings).toEqual ({ tags: ['tag warning'] }) + }) + + it ('updates the reset snapshot only after metadata URL changes', () => { + const current = buildPostImportRow ({ + attributes: { title: 'manual title' }, + metadataUrl: 'https://example.com/old' }) + const validated = buildPostImportRow ({ + attributes: { title: 'new metadata title' }, + metadataUrl: 'https://example.com/new', + fieldWarnings: { title: ['warning'] }, + baseWarnings: ['base warning'] }) + + const result = mergeValidatedImportRows ([current], [validated])[0] + + expect (result?.resetSnapshot).toMatchObject ({ + attributes: { title: 'new metadata title' }, + fieldWarnings: { title: ['warning'] }, + baseWarnings: ['base warning'], + metadataUrl: 'https://example.com/new' }) + }) + + it ('merges result states and clears incompatible post identifiers', () => { + const created = mergeImportResults ([buildPostImportRow ({ + skipReason: 'existing', + existingPostId: 2 })], [{ + sourceRow: 1, + status: 'created', + post: { id: 3 } }])[0] + const skipped = mergeImportResults ([buildPostImportRow ({ + createdPostId: 3, + importStatus: 'created' })], [{ + sourceRow: 1, + status: 'skipped', + existingPostId: 4 }])[0] + const failed = mergeImportResults ([buildPostImportRow ({ + skipReason: 'existing', + existingPostId: 4 })], [{ + sourceRow: 1, + status: 'failed', + errors: { base: ['failure'] } }])[0] + + expect (created).toMatchObject ({ + importStatus: 'created', + createdPostId: 3, + existingPostId: undefined, + skipReason: undefined }) + expect (skipped).toMatchObject ({ + importStatus: 'skipped', + existingPostId: 4, + createdPostId: undefined, + skipReason: 'existing' }) + expect (failed).toMatchObject ({ + importStatus: 'failed', + createdPostId: undefined, + existingPostId: undefined, + skipReason: undefined, + importErrors: { base: ['failure'] } }) + }) + + it ('retries only the selected failed row and counts results exclusively', () => { + const rows = [ + buildPostImportRow ({ sourceRow: 1, importStatus: 'created', createdPostId: 1 }), + buildPostImportRow ({ sourceRow: 2, importStatus: 'skipped', + existingPostId: 2, skipReason: 'existing' }), + buildPostImportRow ({ sourceRow: 3, importStatus: 'failed', + importErrors: { base: ['failed'] } })] + + expect (resultSummaryCounts (rows)).toEqual ({ created: 1, skipped: 1, failed: 1 }) + expect (retryImportRow (rows, 3)[2]).toMatchObject ({ + importStatus: 'pending', + importErrors: undefined }) + }) + + it ('copies reset snapshot values instead of sharing mutable records', () => { + const row = buildPostImportRow ({ fieldWarnings: { title: ['warning'] } }) + const initialised = initialisePreviewRows ([row])[0] + expect (initialised).toBeDefined () + if (initialised == null) + return + + initialised.attributes.title = 'changed' + initialised.fieldWarnings.title?.push ('another') + + expect (initialised.resetSnapshot.attributes.title).toBe ('') + expect (initialised.resetSnapshot.fieldWarnings.title).toEqual (['warning']) + }) +}) diff --git a/frontend/src/lib/postImportSourceValidation.test.ts b/frontend/src/lib/postImportSourceValidation.test.ts new file mode 100644 index 0000000..2f565f5 --- /dev/null +++ b/frontend/src/lib/postImportSourceValidation.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest' + +import { countImportSourceLines, validateImportSource } from '@/lib/postImportSession' + +describe ('post import source validation', () => { + it ('counts trimmed non-empty CRLF and LF rows', () => { + expect (countImportSourceLines (' one \r\n\r\n two\n')).toBe (2) + }) + + it ('reports invalid protocols and malformed URLs with original line numbers', () => { + const issues = validateImportSource ( + '\nftp://example.com/file\nhttps://exa mple.com/path') + + expect (issues).toEqual ([ + { + sourceRow: 2, + message: 'HTTP または HTTPS の URL ではありません.', + url: 'ftp://example.com/file' }, + { + sourceRow: 3, + message: 'URL の形式が不正です.', + url: 'https://exa mple.com/path' }]) + }) + + it ('detects duplicates after frontend URL normalisation', () => { + const issues = validateImportSource ( + 'https://EXAMPLE.com/path/\nhttps://example.com/path') + + expect (issues).toEqual ([{ + sourceRow: 2, + message: '1 行目と同じ URL です.', + url: 'https://example.com/path' }]) + }) + + it ('rejects oversized URLs and rows beyond the maximum count', () => { + const oversized = `https://example.com/${ 'a'.repeat (20 * 1024) }` + const tooMany = Array.from ( + { length: 101 }, + (_, index) => `https://example.com/${ index }`).join ('\n') + + expect (validateImportSource (oversized)[0]).toMatchObject ({ + sourceRow: 1, + message: 'URL が長すぎます.' }) + expect (validateImportSource (tooMany).at (-1)).toEqual ({ + sourceRow: 101, + message: '取込件数は 100 件までです.', + url: 'https://example.com/100' }) + }) +}) diff --git a/frontend/src/lib/postImportStorage.test.ts b/frontend/src/lib/postImportStorage.test.ts new file mode 100644 index 0000000..9bed899 --- /dev/null +++ b/frontend/src/lib/postImportStorage.test.ts @@ -0,0 +1,114 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { cleanupExpiredPostImportSessions, + clearPostImportSourceDraft, + loadPostImportSession, + loadPostImportSourceDraft, + savePostImportSession, + savePostImportSourceDraft } from '@/lib/postImportSession' +import { buildPostImportRow } from '@/test/postImportFactories' + +describe ('post import storage', () => { + beforeEach (() => { + sessionStorage.clear () + vi.useRealTimers () + }) + + it ('round-trips a valid session and source draft', () => { + const row = buildPostImportRow ({ + importStatus: 'skipped', + skipReason: 'existing', + existingPostId: 10 }) + + expect (savePostImportSession ('session', { + source: row.url, + rows: [row], + repairMode: 'all' })).toBe (true) + expect (loadPostImportSession ('session')).toMatchObject ({ + version: 2, + source: row.url, + rows: [{ + importStatus: 'skipped', + skipReason: 'existing', + existingPostId: 10 }] }) + + expect (savePostImportSourceDraft ('https://example.com')).toBe (true) + expect (loadPostImportSourceDraft ()).toEqual ({ source: 'https://example.com' }) + clearPostImportSourceDraft () + expect (loadPostImportSourceDraft ()).toEqual ({ source: '' }) + }) + + it ('rejects inconsistent post IDs and terminal statuses', () => { + const session = { + version: 2, + savedAt: new Date ().toISOString (), + source: '', + repairMode: 'all', + rows: [buildPostImportRow ()] } + const invalidRows = [ + { ...session.rows[0], skipReason: 'existing', existingPostId: undefined }, + { ...session.rows[0], existingPostId: 2, skipReason: undefined }, + { ...session.rows[0], importStatus: 'created', createdPostId: undefined }, + { ...session.rows[0], importStatus: 'failed', createdPostId: 3 }] + + for (const [index, row] of invalidRows.entries ()) + { + sessionStorage.setItem (`post-import-session:invalid-${ index }`, JSON.stringify ({ + ...session, + rows: [row] })) + expect (loadPostImportSession (`invalid-${ index }`)).toBeNull () + } + }) + + it ('rejects invalid attribute, provenance, tag-source, and snapshot data', () => { + const row = buildPostImportRow () + const invalidRows = [ + { ...row, attributes: { title: [] } }, + { ...row, attributes: { unknown: 'value' } }, + { ...row, provenance: { title: 'mapped' } }, + { ...row, tagSources: { mapped: 'tag' } }, + { ...row, resetSnapshot: { ...row.resetSnapshot, + fieldWarnings: { title: 'warning' } } }] + + invalidRows.forEach ((invalidRow, index) => { + sessionStorage.setItem (`post-import-session:shape-${ index }`, JSON.stringify ({ + version: 2, + savedAt: new Date ().toISOString (), + source: '', + rows: [invalidRow], + repairMode: 'all' })) + expect (loadPostImportSession (`shape-${ index }`)).toBeNull () + }) + }) + + it ('removes expired and malformed sessions without touching current sessions', () => { + const current = { + version: 2, + savedAt: new Date ().toISOString (), + source: '', + rows: [buildPostImportRow ()], + repairMode: 'all' } + const expired = { + ...current, + savedAt: new Date (Date.now () - 25 * 60 * 60 * 1000).toISOString () } + sessionStorage.setItem ('post-import-session:current', JSON.stringify (current)) + sessionStorage.setItem ('post-import-session:expired', JSON.stringify (expired)) + sessionStorage.setItem ('post-import-session:malformed', '{') + + cleanupExpiredPostImportSessions () + + expect (sessionStorage.getItem ('post-import-session:current')).not.toBeNull () + expect (sessionStorage.getItem ('post-import-session:expired')).toBeNull () + expect (sessionStorage.getItem ('post-import-session:malformed')).toBeNull () + }) + + it ('reports storage access failures without throwing', () => { + const onError = vi.fn () + vi.spyOn (Storage.prototype, 'setItem').mockImplementation (() => { + throw new DOMException ('quota') + }) + + expect (savePostImportSourceDraft ('source', onError)).toBe (false) + expect (onError).toHaveBeenCalledWith ('ブラウザへ保存できませんでした.') + }) +}) diff --git a/frontend/src/pages/posts/PostImportResultPage.test.tsx b/frontend/src/pages/posts/PostImportResultPage.test.tsx new file mode 100644 index 0000000..4a07415 --- /dev/null +++ b/frontend/src/pages/posts/PostImportResultPage.test.tsx @@ -0,0 +1,82 @@ +import { fireEvent, screen } from '@testing-library/react' +import { Route, Routes, useLocation } from 'react-router-dom' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { savePostImportSession } from '@/lib/postImportSession' +import PostImportResultPage from '@/pages/posts/PostImportResultPage' +import { buildUser } from '@/test/factories' +import { buildPostImportRow } from '@/test/postImportFactories' +import { renderWithProviders } from '@/test/render' + +const api = vi.hoisted (() => ({ apiPost: vi.fn () })) +const toastApi = vi.hoisted (() => ({ toast: vi.fn () })) + +vi.mock ('@/lib/api', () => api) +vi.mock ('@/components/ui/use-toast', () => toastApi) + +const ReviewLocation = () => { + const location = useLocation () + return
{`review route ${ location.search }`}
+} + +const renderPage = () => renderWithProviders ( + + }/> + }/> + , + { route: '/posts/import/session/result' }) + +describe ('PostImportResultPage', () => { + beforeEach (() => { + sessionStorage.clear () + vi.clearAllMocks () + }) + + it ('shows exclusive result counts, post links, and failed-row actions', async () => { + const rows = [ + buildPostImportRow ({ sourceRow: 1, importStatus: 'created', createdPostId: 11 }), + buildPostImportRow ({ sourceRow: 2, importStatus: 'skipped', + skipReason: 'existing', existingPostId: 22 }), + buildPostImportRow ({ sourceRow: 3, importStatus: 'failed', + importErrors: { base: ['登録中に失敗しました.'] } })] + savePostImportSession ('session', { source: '', rows, repairMode: 'all' }) + + renderPage () + + expect (await screen.findByText ( + /登録成功\s*1件.*スキップ\s*1件.*失敗\s*1件/)).toBeInTheDocument () + expect (screen.getAllByRole ('link', { name: '投稿を開く' }) + .map (_1 => _1.getAttribute ('href'))).toEqual (['/posts/11', '/posts/22']) + expect (screen.getByText ('登録中に失敗しました.')).toBeInTheDocument () + expect (screen.getByRole ('button', { name: '編輯' })).toBeInTheDocument () + expect (screen.getByRole ('button', { name: '再試行' })).toBeInTheDocument () + }) + + it ('returns a retry validation error to the review dialogue route', async () => { + const failed = buildPostImportRow ({ + importStatus: 'failed', + importErrors: { base: ['old error'] } }) + const invalid = buildPostImportRow ({ + importStatus: 'pending', + status: 'error', + validationErrors: { title: ['タイトルを確認してください.'] } }) + savePostImportSession ('session', { + source: failed.url, + rows: [failed], + repairMode: 'all' }) + api.apiPost.mockResolvedValue ({ rows: [invalid] }) + + renderPage () + fireEvent.click (await screen.findByRole ('button', { name: '再試行' })) + + expect (await screen.findByText ('review route ?edit=1')).toBeInTheDocument () + expect (api.apiPost).toHaveBeenCalledTimes (1) + const saved = JSON.parse ( + sessionStorage.getItem ('post-import-session:session') ?? '{}') + expect (saved.rows[0]).toMatchObject ({ + importStatus: 'pending', + validationErrors: { title: ['タイトルを確認してください.'] } }) + }) +}) diff --git a/frontend/src/pages/posts/PostImportReviewPage.test.tsx b/frontend/src/pages/posts/PostImportReviewPage.test.tsx new file mode 100644 index 0000000..3e6d409 --- /dev/null +++ b/frontend/src/pages/posts/PostImportReviewPage.test.tsx @@ -0,0 +1,78 @@ +import { fireEvent, screen, waitFor } from '@testing-library/react' +import { Route, Routes } from 'react-router-dom' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { savePostImportSession } from '@/lib/postImportSession' +import PostImportReviewPage from '@/pages/posts/PostImportReviewPage' +import { buildUser } from '@/test/factories' +import { buildPostImportRow } from '@/test/postImportFactories' +import { renderWithProviders } from '@/test/render' + +const api = vi.hoisted (() => ({ apiPost: vi.fn () })) +const toastApi = vi.hoisted (() => ({ toast: vi.fn () })) + +vi.mock ('@/lib/api', () => api) +vi.mock ('@/components/ui/use-toast', () => toastApi) + +const renderPage = () => renderWithProviders ( + + }/> + result route}/> + , + { route: '/posts/import/session/review' }) + +describe ('PostImportReviewPage', () => { + beforeEach (() => { + sessionStorage.clear () + vi.clearAllMocks () + }) + + it ('keeps the review route and opens the first invalid row dialogue', async () => { + const row = buildPostImportRow ({ attributes: { title: 'title' } }) + savePostImportSession ('session', { source: row.url, rows: [row], repairMode: 'all' }) + api.apiPost.mockResolvedValue ({ rows: [buildPostImportRow ({ + attributes: { title: 'title' }, + status: 'error', + validationErrors: { title: ['タイトルを確認してください.'] } })] }) + + renderPage () + fireEvent.click (await screen.findByRole ('button', { name: '取込実行' })) + + expect (await screen.findByText ('投稿を編輯')).toBeInTheDocument () + expect (screen.getByText ('タイトルを確認してください.')).toBeInTheDocument () + expect (api.apiPost).toHaveBeenCalledTimes (1) + expect (screen.queryByText ('result route')).not.toBeInTheDocument () + }) + + it ('submits existing rows for a formal skipped result and opens result route', async () => { + const row = buildPostImportRow ({ + skipReason: 'existing', + existingPostId: 10 }) + savePostImportSession ('session', { source: row.url, rows: [row], repairMode: 'all' }) + api.apiPost + .mockResolvedValueOnce ({ rows: [row] }) + .mockResolvedValueOnce ({ + created: 0, + skipped: 1, + failed: 0, + rows: [{ sourceRow: 1, status: 'skipped', existingPostId: 10 }] }) + + renderPage () + fireEvent.click (await screen.findByRole ('button', { name: '取込実行' })) + + expect (await screen.findByText ('result route')).toBeInTheDocument () + expect (api.apiPost).toHaveBeenCalledTimes (2) + expect (api.apiPost.mock.calls[1]?.[1]).toMatchObject ({ + rows: [expect.objectContaining ({ sourceRow: 1, url: row.url })] }) + await waitFor (() => { + const saved = JSON.parse ( + sessionStorage.getItem ('post-import-session:session') ?? '{}') + expect (saved.rows[0]).toMatchObject ({ + importStatus: 'skipped', + existingPostId: 10, + skipReason: 'existing' }) + }) + }) +}) diff --git a/frontend/src/pages/posts/PostImportSourcePage.test.tsx b/frontend/src/pages/posts/PostImportSourcePage.test.tsx new file mode 100644 index 0000000..a8d5af5 --- /dev/null +++ b/frontend/src/pages/posts/PostImportSourcePage.test.tsx @@ -0,0 +1,92 @@ +import { fireEvent, screen, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import PostImportSourcePage from '@/pages/posts/PostImportSourcePage' +import { buildUser } from '@/test/factories' +import { buildPostImportRow } from '@/test/postImportFactories' +import { renderWithProviders } from '@/test/render' + +const api = vi.hoisted (() => ({ + apiPost: vi.fn (), + isApiError: vi.fn () })) + +const router = vi.hoisted (() => ({ navigate: vi.fn () })) + +const toastApi = vi.hoisted (() => ({ toast: vi.fn () })) + +vi.mock ('@/lib/api', () => api) +vi.mock ('@/components/ui/use-toast', () => toastApi) +vi.mock ('react-router-dom', async importOriginal => ({ + ...await importOriginal (), + useNavigate: () => router.navigate })) + +describe ('PostImportSourcePage', () => { + beforeEach (() => { + sessionStorage.clear () + vi.clearAllMocks () + api.isApiError.mockReturnValue (false) + }) + + it ('shows no empty error initially and validates only after Next is pressed', () => { + renderWithProviders () + + expect (screen.queryByText ('URL を入力してください.')).not.toBeInTheDocument () + fireEvent.click (screen.getByRole ('button', { name: '次へ' })) + expect (screen.getByText ('URL を入力してください.')).toBeInTheDocument () + expect (api.apiPost).not.toHaveBeenCalled () + }) + + it ('shows frontend URL issues with line numbers without requesting preview', () => { + renderWithProviders () + const input = screen.getByRole ('textbox', { name: '' }) + + fireEvent.change (input, { + target: { value: '\nftp://example.com/file\nhttps://example.com/valid' } }) + expect (screen.queryByText (/2 行目/)).not.toBeInTheDocument () + fireEvent.click (screen.getByRole ('button', { name: '次へ' })) + + expect (screen.getByText (/2 行目: HTTP または HTTPS/)).toBeInTheDocument () + expect (screen.getByText ('ftp://example.com/file')).toBeInTheDocument () + expect (input).toHaveAttribute ('aria-invalid', 'true') + expect (input.getAttribute ('aria-describedby')).toContain ('post-import-source-issues') + expect (api.apiPost).not.toHaveBeenCalled () + }) + + it ('shows backend URL errors against original input without a session', async () => { + api.apiPost.mockResolvedValue ({ + rows: [buildPostImportRow ({ + sourceRow: 2, + url: 'https://example.com/canonical', + status: 'error', + validationErrors: { url: ['URL が重複しています.'] } })] }) + renderWithProviders () + + fireEvent.change (screen.getByRole ('textbox', { name: '' }), { + target: { value: '\nhttps://example.com/original' } }) + fireEvent.click (screen.getByRole ('button', { name: '次へ' })) + + expect (await screen.findByText ('2 行目: URL が重複しています.')).toBeInTheDocument () + expect (screen.getAllByText ('https://example.com/original')).toHaveLength (2) + expect (router.navigate).not.toHaveBeenCalled () + expect (Array.from ({ length: sessionStorage.length }, (_, index) => + sessionStorage.key (index))).not.toContainEqual( + expect.stringMatching (/^post-import-session:/)) + }) + + it ('stores a successful preview before navigating to the review route', async () => { + api.apiPost.mockResolvedValue ({ rows: [buildPostImportRow ()] }) + renderWithProviders () + + fireEvent.change (screen.getByRole ('textbox', { name: '' }), { + target: { value: 'https://example.com/post' } }) + fireEvent.click (screen.getByRole ('button', { name: '次へ' })) + + await waitFor (() => { + expect (router.navigate).toHaveBeenCalledWith ( + expect.stringMatching (/^\/posts\/import\/[^/]+\/review$/)) + }) + const sessionKeys = Array.from ({ length: sessionStorage.length }, (_, index) => + sessionStorage.key (index)).filter (_1 => _1?.startsWith ('post-import-session:')) + expect (sessionKeys).toHaveLength (1) + }) +}) diff --git a/frontend/src/test/postImportFactories.ts b/frontend/src/test/postImportFactories.ts new file mode 100644 index 0000000..cc6a10a --- /dev/null +++ b/frontend/src/test/postImportFactories.ts @@ -0,0 +1,52 @@ +import type { PostImportRow } from '@/lib/postImportSession' + +export const buildPostImportRow = ( + overrides: Partial = {}, +): PostImportRow => { + const attributes = { + title: '', + thumbnailBase: '', + originalCreatedFrom: '', + originalCreatedBefore: '', + duration: '', + tags: '', + parentPostIds: '', + ...overrides.attributes } + const provenance = { + url: 'manual' as const, + title: 'automatic' as const, + thumbnailBase: 'automatic' as const, + originalCreatedFrom: 'automatic' as const, + originalCreatedBefore: 'automatic' as const, + duration: 'automatic' as const, + tags: 'automatic' as const, + parentPostIds: 'automatic' as const, + ...overrides.provenance } + const fieldWarnings = { ...overrides.fieldWarnings } + const baseWarnings = [...(overrides.baseWarnings ?? [])] + const tagSources = { + automatic: overrides.tagSources?.automatic ?? '', + manual: overrides.tagSources?.manual ?? '' } + const url = overrides.url ?? 'https://example.com/post' + + return { + ...overrides, + sourceRow: overrides.sourceRow ?? 1, + url, + attributes, + fieldWarnings, + baseWarnings, + validationErrors: overrides.validationErrors ?? {}, + provenance, + tagSources, + status: overrides.status ?? 'ready', + resetSnapshot: overrides.resetSnapshot ?? { + url, + attributes: { ...attributes }, + provenance: { ...provenance }, + tagSources: { ...tagSources }, + fieldWarnings: Object.fromEntries ( + Object.entries (fieldWarnings).map (([key, values]) => [key, [...values]])), + baseWarnings: [...baseWarnings], + metadataUrl: overrides.metadataUrl } } +}