このコミットが含まれているのは:
2026-05-13 20:42:25 +09:00
コミット 0a13c00f37
48個のファイルの変更2378行の追加7行の削除
+96
ファイルの表示
@@ -0,0 +1,96 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted (() => {
const client = {
delete: vi.fn (),
get: vi.fn (),
patch: vi.fn (),
post: vi.fn (),
put: vi.fn (),
}
return {
client,
isAxiosError: vi.fn (),
}
})
vi.mock ('axios', () => ({
default: {
create: vi.fn (() => mocks.client),
isAxiosError: mocks.isAxiosError,
},
}))
vi.mock ('@/config', () => ({
API_BASE_URL: '/api',
}))
describe ('api helpers', () => {
beforeEach (() => {
vi.clearAllMocks ()
localStorage.clear ()
})
it ('adds the transfer code header and camelizes get responses', async () => {
localStorage.setItem ('user_code', 'abc123')
mocks.client.get.mockResolvedValueOnce ({
data: { post_id: 1, nested_value: { created_at: 'now' } },
})
const { apiGet } = await import ('@/lib/api')
const data = await apiGet<{ postId: number; nestedValue: { createdAt: string } }> (
'/posts/1',
{ headers: { 'X-Extra': '1' }, params: { page: 2 } },
)
expect (mocks.client.get).toHaveBeenCalledWith (
'/posts/1',
{
headers: { 'X-Transfer-Code': 'abc123', 'X-Extra': '1' },
params: { page: 2 },
},
)
expect (data).toEqual ({ postId: 1, nestedValue: { createdAt: 'now' } })
})
it ('passes an empty body for post-like requests when body is omitted', async () => {
mocks.client.patch.mockResolvedValueOnce ({ data: { ok_value: true } })
const { apiPatch } = await import ('@/lib/api')
const data = await apiPatch<{ okValue: boolean }> ('/posts/1')
expect (mocks.client.patch).toHaveBeenCalledWith (
'/posts/1',
{},
{ headers: { 'X-Transfer-Code': '' } },
)
expect (data.okValue).toBe (true)
})
it ('does not camelize blob responses', async () => {
const blob = new Blob (['csv'])
mocks.client.get.mockResolvedValueOnce ({ data: blob })
const { apiGet } = await import ('@/lib/api')
const data = await apiGet<Blob> ('/exports', { responseType: 'blob' })
expect (data).toBe (blob)
})
it ('delegates deletes and exposes axios error detection', async () => {
const err = new Error ('bad')
mocks.client.delete.mockResolvedValueOnce ({})
mocks.isAxiosError.mockReturnValueOnce (true)
const { apiDelete, isApiError } = await import ('@/lib/api')
await apiDelete ('/posts/1')
expect (mocks.client.delete).toHaveBeenCalledWith (
'/posts/1',
{ headers: { 'X-Transfer-Code': '' } },
)
expect (isApiError (err)).toBe (true)
expect (mocks.isAxiosError).toHaveBeenCalledWith (err)
})
})
+117
ファイルの表示
@@ -0,0 +1,117 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { fetchPostChanges, fetchPosts, toggleViewedFlg, updatePost } from '@/lib/posts'
import type { FetchPostsParams } from '@/types'
const api = vi.hoisted (() => ({
apiDelete: vi.fn (),
apiGet: vi.fn (),
apiPost: vi.fn (),
apiPut: vi.fn (),
}))
vi.mock ('@/lib/api', () => api)
const baseParams: FetchPostsParams = {
url: '',
title: '',
tags: '',
match: 'all',
originalCreatedFrom: '',
originalCreatedTo: '',
createdFrom: '',
createdTo: '',
updatedFrom: '',
updatedTo: '',
page: 1,
limit: 20,
order: 'updated_at:desc',
}
describe ('posts API functions', () => {
beforeEach (() => {
vi.clearAllMocks ()
})
it ('maps post search parameters to backend snake_case names', async () => {
api.apiGet.mockResolvedValueOnce ({ posts: [], count: 0 })
await fetchPosts ({
...baseParams,
title: 'title',
tags: 'a b',
originalCreatedFrom: '2026-01-01',
updatedTo: '2026-02-01',
})
expect (api.apiGet).toHaveBeenCalledWith (
'/posts',
{
params: {
title: 'title',
tags: 'a b',
match: 'all',
original_created_from: '2026-01-01',
updated_to: '2026-02-01',
page: 1,
limit: 20,
order: 'updated_at:desc',
},
},
)
})
it ('updates posts with version and merge controls', async () => {
api.apiPut.mockResolvedValueOnce ({ id: 5 })
await updatePost (
{
id: 5,
title: 'new title',
tags: 'tag',
parentPostIds: '1 2',
originalCreatedFrom: null,
originalCreatedBefore: '2026-01-02T00:00:00Z',
},
{ baseVersionNo: 7, force: true, merge: false },
)
expect (api.apiPut).toHaveBeenCalledWith (
'/posts/5',
{
title: 'new title',
tags: 'tag',
parent_post_ids: '1 2',
original_created_from: null,
original_created_before: '2026-01-02T00:00:00Z',
},
{
params: {
base_version_no: '7',
force: '1',
merge: '0',
},
},
)
})
it ('uses the viewed endpoint method matching the requested state', async () => {
await toggleViewedFlg ('9', true)
await toggleViewedFlg ('9', false)
expect (api.apiPost).toHaveBeenCalledWith ('/posts/9/viewed')
expect (api.apiDelete).toHaveBeenCalledWith ('/posts/9/viewed')
})
it ('keeps optional post history filters out when blank', async () => {
api.apiGet.mockResolvedValueOnce ({ versions: [], count: 0 })
await fetchPostChanges ({ page: 2, limit: 50 })
expect (api.apiGet).toHaveBeenCalledWith (
'/posts/versions',
{ params: { page: 2, limit: 50 } },
)
})
})
+112
ファイルの表示
@@ -0,0 +1,112 @@
import { QueryClient } from '@tanstack/react-query'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { prefetchForURL } from '@/lib/prefetchers'
const postsApi = vi.hoisted (() => ({
fetchPost: vi.fn (),
fetchPostChanges: vi.fn (),
fetchPosts: vi.fn (),
}))
const tagsApi = vi.hoisted (() => ({
fetchTag: vi.fn (),
fetchTagByName: vi.fn (),
fetchTagChanges: vi.fn (),
fetchTags: vi.fn (),
}))
const wikiApi = vi.hoisted (() => ({
fetchWikiPage: vi.fn (),
fetchWikiPageByTitle: vi.fn (),
fetchWikiPages: vi.fn (),
}))
vi.mock ('@/lib/posts', () => postsApi)
vi.mock ('@/lib/tags', () => tagsApi)
vi.mock ('@/lib/wiki', () => wikiApi)
const qc = () => new QueryClient ({
defaultOptions: { queries: { retry: false } },
})
describe ('prefetchForURL', () => {
beforeEach (() => {
vi.clearAllMocks ()
postsApi.fetchPosts.mockResolvedValue ({ posts: [], count: 0 })
postsApi.fetchPost.mockResolvedValue ({ id: 1 })
postsApi.fetchPostChanges.mockResolvedValue ({ versions: [], count: 0 })
tagsApi.fetchTags.mockResolvedValue ({ tags: [], count: 0 })
tagsApi.fetchTag.mockResolvedValue ({ id: 1 })
tagsApi.fetchTagByName.mockResolvedValue (null)
tagsApi.fetchTagChanges.mockResolvedValue ({ versions: [], count: 0 })
wikiApi.fetchWikiPages.mockResolvedValue ([])
wikiApi.fetchWikiPage.mockResolvedValue ({ id: 1 })
wikiApi.fetchWikiPageByTitle.mockResolvedValue (null)
})
it ('prefetches post indexes from query parameters', async () => {
await prefetchForURL (
qc (),
'http://localhost/posts?tags=a+b&match=any&page=2&limit=5&order=title%3Aasc',
)
expect (postsApi.fetchPosts).toHaveBeenCalledWith (
expect.objectContaining ({
tags: 'a b',
match: 'any',
page: 2,
limit: 5,
order: 'title:asc',
}),
)
})
it ('prefetches post detail pages', async () => {
await prefetchForURL (qc (), 'http://localhost/posts/12')
expect (postsApi.fetchPost).toHaveBeenCalledWith ('12')
})
it ('prefetches tag indexes from query parameters', async () => {
await prefetchForURL (
qc (),
'http://localhost/tags?post=9&name=x&category=general&page=4&post_count_lte=10',
)
expect (tagsApi.fetchTags).toHaveBeenCalledWith (
expect.objectContaining ({
post: 9,
name: 'x',
category: 'general',
page: 4,
postCountLTE: 10,
}),
)
})
it ('prefetches wiki show pages and related tag/post data', async () => {
wikiApi.fetchWikiPageByTitle.mockResolvedValueOnce ({
id: 3,
title: 'Actual',
body: 'body',
})
await prefetchForURL (qc (), 'http://localhost/wiki/Alias')
expect (wikiApi.fetchWikiPageByTitle).toHaveBeenCalledWith ('Alias', { version: undefined })
expect (wikiApi.fetchWikiPage).toHaveBeenCalledWith ('3', {})
expect (tagsApi.fetchTagByName).toHaveBeenCalledWith ('Actual')
expect (postsApi.fetchPosts).toHaveBeenCalledWith (
expect.objectContaining ({ tags: 'Actual', limit: 8 }),
)
})
it ('ignores routes without a prefetcher', async () => {
await prefetchForURL (qc (), 'http://localhost/unknown')
expect (postsApi.fetchPosts).not.toHaveBeenCalled ()
expect (tagsApi.fetchTags).not.toHaveBeenCalled ()
expect (wikiApi.fetchWikiPages).not.toHaveBeenCalled ()
})
})
+14
ファイルの表示
@@ -0,0 +1,14 @@
import { describe, expect, it } from 'vitest'
import { postsKeys, tagsKeys, wikiKeys } from '@/lib/queryKeys'
describe ('query keys', () => {
it ('uses stable namespaces for posts, tags, and wiki', () => {
expect (postsKeys.show ('3')).toEqual (['posts', '3'])
expect (postsKeys.related ('3')).toEqual (['related', '3'])
expect (tagsKeys.deerjikists ('7')).toEqual (['tags', 'deerjikists', '7'])
expect (wikiKeys.show ('Title', { version: '2' })).toEqual (
['wiki', 'Title', { version: '2' }],
)
})
})
+68
ファイルの表示
@@ -0,0 +1,68 @@
import { describe, expect, it } from 'vitest'
import remarkWikiAutolink from '@/lib/remark-wiki-autolink'
import type { Root } from 'mdast'
describe ('remarkWikiAutolink', () => {
it ('links matching wiki page names and prefers longer matches', () => {
const tree: Root = {
type: 'root',
children: [{
type: 'paragraph',
children: [{ type: 'text', value: '虹夏 and 虹' }],
}],
}
remarkWikiAutolink (['虹', '虹夏']) (tree)
expect (tree.children[0]).toMatchObject ({
type: 'paragraph',
children: [
{
type: 'link',
url: '/wiki/%E8%99%B9%E5%A4%8F',
children: [{ type: 'text', value: '虹夏' }],
},
{ type: 'text', value: ' and ' },
{
type: 'link',
url: '/wiki/%E8%99%B9',
children: [{ type: 'text', value: '虹' }],
},
],
})
})
it ('does not link text inside existing links or code', () => {
const tree: Root = {
type: 'root',
children: [
{
type: 'paragraph',
children: [{
type: 'link',
url: '/existing',
children: [{ type: 'text', value: '虹' }],
}],
},
{
type: 'code',
value: '虹',
},
],
}
remarkWikiAutolink (['虹']) (tree)
expect (tree.children[0]).toMatchObject ({
type: 'paragraph',
children: [{
type: 'link',
url: '/existing',
children: [{ type: 'text', value: '虹' }],
}],
})
expect (tree.children[1]).toMatchObject ({ type: 'code', value: '虹' })
})
})
+67
ファイルの表示
@@ -0,0 +1,67 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { fetchTag, fetchTagByName, fetchTags } from '@/lib/tags'
import type { FetchTagsParams } from '@/types'
const api = vi.hoisted (() => ({
apiGet: vi.fn (),
}))
vi.mock ('@/lib/api', () => api)
const baseParams: FetchTagsParams = {
post: null,
name: '',
category: null,
postCountGTE: 0,
postCountLTE: null,
createdFrom: '',
createdTo: '',
updatedFrom: '',
updatedTo: '',
page: 1,
limit: 30,
order: 'updated_at:desc',
}
describe ('tags API functions', () => {
beforeEach (() => {
vi.clearAllMocks ()
})
it ('maps tag filters to backend parameters', async () => {
api.apiGet.mockResolvedValueOnce ({ tags: [], count: 0 })
await fetchTags ({
...baseParams,
name: '虹',
category: 'character',
postCountGTE: 10,
postCountLTE: 20,
})
expect (api.apiGet).toHaveBeenCalledWith (
'/tags',
{
params: {
name: '虹',
category: 'character',
post_count_gte: 10,
post_count_lte: 20,
page: 1,
limit: 30,
order: 'updated_at:desc',
},
},
)
})
it ('returns null when tag fetches fail', async () => {
api.apiGet.mockRejectedValueOnce (new Error ('missing'))
api.apiGet.mockRejectedValueOnce (new Error ('missing'))
await expect (fetchTag ('1')).resolves.toBeNull ()
await expect (fetchTagByName ('unknown')).resolves.toBeNull ()
})
})
+28
ファイルの表示
@@ -0,0 +1,28 @@
import { describe, expect, it } from 'vitest'
import { cn, originalCreatedAtString, toDate } from '@/lib/utils'
describe ('utils', () => {
it ('converts strings to dates and leaves date instances intact', () => {
const date = new Date ('2026-01-02T03:04:05Z')
expect (toDate ('2026-01-02T03:04:05Z')).toBeInstanceOf (Date)
expect (toDate (date)).toBe (date)
})
it ('merges conditional Tailwind classes', () => {
const hidden = false
expect (cn ('p-2', hidden && 'hidden', 'p-4')).toBe ('p-4')
})
it ('renders unknown original creation time ranges', () => {
expect (originalCreatedAtString (null, null)).toBe ('年月日不詳')
expect (
originalCreatedAtString (
'2026-01-01T00:00:00+09:00',
'2026-01-02T00:00:00+09:00',
),
).toContain ('時刻不詳')
})
})
+48
ファイルの表示
@@ -0,0 +1,48 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { fetchWikiPage, fetchWikiPageByTitle, fetchWikiPages } from '@/lib/wiki'
const api = vi.hoisted (() => ({
apiGet: vi.fn (),
}))
vi.mock ('@/lib/api', () => api)
describe ('wiki API functions', () => {
beforeEach (() => {
vi.clearAllMocks ()
})
it ('fetches wiki index and show pages with expected parameters', async () => {
api.apiGet.mockResolvedValueOnce ([])
api.apiGet.mockResolvedValueOnce ({ id: 1 })
await fetchWikiPages ({ title: '虹' })
await fetchWikiPage ('1', { version: '3' })
expect (api.apiGet).toHaveBeenNthCalledWith (
1,
'/wiki',
{ params: { title: '虹' } },
)
expect (api.apiGet).toHaveBeenNthCalledWith (
2,
'/wiki/1',
{ params: { version: '3' } },
)
})
it ('encodes title path segments and returns null on misses', async () => {
api.apiGet.mockResolvedValueOnce ({ id: 2 })
api.apiGet.mockRejectedValueOnce (new Error ('missing'))
await fetchWikiPageByTitle ('a/b c', { version: undefined })
await expect (fetchWikiPageByTitle ('missing', {})).resolves.toBeNull ()
expect (api.apiGet).toHaveBeenNthCalledWith (
1,
'/wiki/title/a%2Fb%20c',
{ params: { version: undefined } },
)
})
})