diff --git a/backend/app/controllers/nico_tags_controller.rb b/backend/app/controllers/nico_tags_controller.rb
index c897fe0..ea02a12 100644
--- a/backend/app/controllers/nico_tags_controller.rb
+++ b/backend/app/controllers/nico_tags_controller.rb
@@ -24,6 +24,7 @@ class NicoTagsController < ApplicationController
ExternalTag
.joins("LEFT JOIN (#{ post_tag_max_sql }) post_tag_max " \
'ON post_tag_max.external_tag_id = external_tags.id')
+ .includes(linked_tags: { tag_name: :wiki_page })
if name
q = q.where(('external_tags.name LIKE ? ' +
"OR CONCAT(external_tags.platform, ':', external_tags.name) LIKE ?"),
diff --git a/frontend/src/components/DraggableDroppableTagRow.test.tsx b/frontend/src/components/DraggableDroppableTagRow.test.tsx
index 8469831..43a32aa 100644
--- a/frontend/src/components/DraggableDroppableTagRow.test.tsx
+++ b/frontend/src/components/DraggableDroppableTagRow.test.tsx
@@ -13,11 +13,14 @@ vi.mock ('@dnd-kit/core', () => dndKit)
const tag = buildTag ({ id: 7, name: 'ドラッグ元', postCount: 3 })
-const renderRow = (activeDndId?: string) => {
+const renderRow = (
+ activeDndId?: string,
+ renderedTag = tag,
+) => {
renderWithProviders (
,
@@ -72,4 +75,26 @@ describe ('DraggableDroppableTagRow', () => {
renderRow ('tag-node:other')
expect (tagBody ()).toHaveStyle ({ visibility: 'visible' })
})
+
+ it ('disables drag and drop for external tags', () => {
+ const external = buildTag ({
+ id: 7,
+ name: 'nico:external',
+ category: 'nico',
+ })
+
+ renderRow (undefined, external)
+
+ expect (dndKit.useDraggable).toHaveBeenCalledWith (
+ expect.objectContaining ({
+ disabled: true,
+ }),
+ )
+
+ expect (dndKit.useDroppable).toHaveBeenCalledWith (
+ expect.objectContaining ({
+ disabled: true,
+ }),
+ )
+ })
})
diff --git a/frontend/src/components/TagDetailSidebar.test.tsx b/frontend/src/components/TagDetailSidebar.test.tsx
new file mode 100644
index 0000000..e89683f
--- /dev/null
+++ b/frontend/src/components/TagDetailSidebar.test.tsx
@@ -0,0 +1,66 @@
+import { screen } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+import TagDetailSidebar from '@/components/TagDetailSidebar'
+import { setClientTagRelationDisplayMode } from '@/lib/settings'
+import { buildPost, buildTag } from '@/test/factories'
+import { renderWithProviders } from '@/test/render'
+
+import type { ReactNode } from 'react'
+
+vi.mock ('@/components/TagSearch', () => ({
+ default: () => null,
+}))
+
+vi.mock ('@/components/DraggableDroppableTagRow', () => ({
+ default: ({ tag }: { tag: { name: string } }) => (
+ {tag.name}
+ ),
+}))
+
+vi.mock ('@dnd-kit/core', () => ({
+ DndContext: ({ children }: { children: ReactNode }) => <>{children}>,
+ DragOverlay: ({ children }: { children: ReactNode }) => <>{children}>,
+ MeasuringStrategy: { Always: 'always' },
+ MouseSensor: vi.fn (),
+ TouchSensor: vi.fn (),
+ pointerWithin: vi.fn (),
+ useDroppable: vi.fn (() => ({
+ setNodeRef: vi.fn (),
+ isOver: false,
+ })),
+ useSensor: vi.fn (() => ({ })),
+ useSensors: vi.fn (() => []),
+}))
+
+describe ('TagDetailSidebar', () => {
+ beforeEach (() => {
+ localStorage.clear ()
+ vi.clearAllMocks ()
+ })
+
+ it ('keeps internal and external tags with the same numeric id in flat mode', () => {
+ setClientTagRelationDisplayMode ('flat')
+
+ const internal = buildTag ({
+ id: 7,
+ name: 'internal_collision',
+ category: 'general',
+ })
+ const external = buildTag ({
+ id: 7,
+ name: 'nico:external_collision',
+ category: 'nico',
+ })
+
+ renderWithProviders (
+ ,
+ )
+
+ expect (screen.getByText ('internal_collision')).toBeInTheDocument ()
+ expect (screen.getByText ('nico:external_collision')).toBeInTheDocument ()
+ })
+})
diff --git a/frontend/src/components/TagLink.test.tsx b/frontend/src/components/TagLink.test.tsx
index e6a073c..8922265 100644
--- a/frontend/src/components/TagLink.test.tsx
+++ b/frontend/src/components/TagLink.test.tsx
@@ -57,4 +57,31 @@ describe ('TagLink', () => {
expect (screen.getByText ('正式名')).toBeInTheDocument ()
expect (screen.queryByRole ('link')).not.toBeInTheDocument ()
})
+
+ it ('does not show a missing-information marker for external tags', () => {
+ renderWithProviders (
+ ,
+ )
+
+ expect (
+ screen.getByRole ('link', { name: 'nico:external' }),
+ ).toBeInTheDocument ()
+
+ expect (
+ screen.queryByRole ('link', { name: '!' }),
+ ).not.toBeInTheDocument ()
+
+ expect (
+ screen.queryByTitle ('nico:external Wiki が存在しません.'),
+ ).not.toBeInTheDocument ()
+ })
})
diff --git a/frontend/src/lib/posts.test.ts b/frontend/src/lib/posts.test.ts
index 600df31..972775d 100644
--- a/frontend/src/lib/posts.test.ts
+++ b/frontend/src/lib/posts.test.ts
@@ -116,4 +116,25 @@ describe ('posts API functions', () => {
{ params: { page: 2, limit: 50 } },
)
})
+
+ it ('maps an explicit external tag history filter to external_tag', async () => {
+ api.apiGet.mockResolvedValueOnce ({ versions: [], count: 0 })
+
+ await fetchPostChanges ({
+ externalTag: '7',
+ page: 2,
+ limit: 50,
+ })
+
+ expect (api.apiGet).toHaveBeenCalledWith (
+ '/posts/versions',
+ {
+ params: {
+ external_tag: '7',
+ page: 2,
+ limit: 50,
+ },
+ },
+ )
+ })
})
diff --git a/frontend/src/lib/prefetchers.test.ts b/frontend/src/lib/prefetchers.test.ts
index 8a341a3..b147c79 100644
--- a/frontend/src/lib/prefetchers.test.ts
+++ b/frontend/src/lib/prefetchers.test.ts
@@ -137,4 +137,19 @@ describe ('prefetchForURL', () => {
expect (tagsApi.fetchTags).not.toHaveBeenCalled ()
expect (wikiApi.fetchWikiPages).not.toHaveBeenCalled ()
})
+
+ it ('prefetches external tag post history without treating it as an internal tag', async () => {
+ await prefetchForURL (
+ qc (),
+ 'http://localhost/posts/changes?external_tag=12&page=2&limit=50',
+ )
+
+ expect (postsApi.fetchPostChanges).toHaveBeenCalledWith ({
+ externalTag: '12',
+ page: 2,
+ limit: 50,
+ })
+
+ expect (tagsApi.fetchTag).not.toHaveBeenCalled ()
+ })
})
diff --git a/frontend/src/pages/posts/PostHistoryPage.test.tsx b/frontend/src/pages/posts/PostHistoryPage.test.tsx
new file mode 100644
index 0000000..44b2ee5
--- /dev/null
+++ b/frontend/src/pages/posts/PostHistoryPage.test.tsx
@@ -0,0 +1,44 @@
+import { waitFor } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+import PostHistoryPage from '@/pages/posts/PostHistoryPage'
+import { renderWithProviders } from '@/test/render'
+
+const postsApi = vi.hoisted (() => ({
+ fetchPostChanges: vi.fn (),
+ updatePost: vi.fn (),
+}))
+
+const tagsApi = vi.hoisted (() => ({
+ fetchTag: vi.fn (),
+}))
+
+vi.mock ('@/lib/posts', () => postsApi)
+vi.mock ('@/lib/tags', () => tagsApi)
+
+describe ('PostHistoryPage', () => {
+ beforeEach (() => {
+ vi.clearAllMocks ()
+ postsApi.fetchPostChanges.mockResolvedValue ({
+ versions: [],
+ count: 0,
+ })
+ })
+
+ it ('filters by external_tag without resolving the id as an internal tag', async () => {
+ renderWithProviders (
+ ,
+ { route: '/posts/changes?external_tag=7' },
+ )
+
+ await waitFor (() => {
+ expect (postsApi.fetchPostChanges).toHaveBeenCalledWith ({
+ externalTag: '7',
+ page: 1,
+ limit: 20,
+ })
+ })
+
+ expect (tagsApi.fetchTag).not.toHaveBeenCalled ()
+ })
+})
diff --git a/frontend/src/pages/tags/TagListPage.test.tsx b/frontend/src/pages/tags/TagListPage.test.tsx
index 3d0e9ae..a2d6763 100644
--- a/frontend/src/pages/tags/TagListPage.test.tsx
+++ b/frontend/src/pages/tags/TagListPage.test.tsx
@@ -1,4 +1,4 @@
-import { fireEvent, screen, waitFor } from '@testing-library/react'
+import { fireEvent, screen, waitFor, within } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import TagListPage from '@/pages/tags/TagListPage'
@@ -127,4 +127,49 @@ describe ('TagListPage', () => {
)
})
})
+
+ it ('keeps colliding internal and external tags on their own routes', async () => {
+ const internal = buildTag ({
+ id: 7,
+ name: 'internal_collision',
+ category: 'general',
+ })
+ const external = buildTag ({
+ id: 7,
+ name: 'nico:external_collision',
+ category: 'nico',
+ })
+
+ tagsApi.fetchTags.mockResolvedValueOnce ({
+ tags: [internal, external],
+ count: 2,
+ })
+
+ renderWithProviders (, { route: '/tags' })
+
+ const internalLink =
+ await screen.findByRole ('link', { name: 'internal_collision' })
+ const externalLink =
+ screen.getByRole ('link', { name: 'nico:external_collision' })
+
+ expect (internalLink).toHaveAttribute ('href', '/tags/7')
+ expect (externalLink).toHaveAttribute (
+ 'href',
+ '/tags/nico?name=nico%3Aexternal_collision',
+ )
+
+ const internalRow = internalLink.closest ('tr')
+ const externalRow = externalLink.closest ('tr')
+
+ expect (internalRow).not.toBeNull ()
+ expect (externalRow).not.toBeNull ()
+
+ expect (
+ within (internalRow!).getByRole ('link', { name: '耕作履歴' }),
+ ).toHaveAttribute ('href', '/posts/changes?tag=7')
+
+ expect (
+ within (externalRow!).getByRole ('link', { name: '耕作履歴' }),
+ ).toHaveAttribute ('href', '/posts/changes?external_tag=7')
+ })
})