Route all feature requests through business API modules, sync generated DTOs, and add communication tests and architecture documentation. BREAKING CHANGE: migrate the client to plural resource paths, canonical snake_case fields, ISO timestamps, and explicit paginated responses.
135 lines
5.6 KiB
JavaScript
135 lines
5.6 KiB
JavaScript
import assert from 'node:assert/strict'
|
|
import { afterEach, mock, test } from 'node:test'
|
|
import { apiRequest, ApiError, AUTH_EXPIRED_EVENT } from '../src/lib/api.ts'
|
|
import { listClouds, uploadCloud, deleteClouds } from '../src/features/clouds/api.ts'
|
|
import { collectPages } from '../src/lib/pagination.ts'
|
|
|
|
afterEach(() => mock.restoreAll())
|
|
|
|
const cloud = {
|
|
id: 'cloud-1',
|
|
owner: { id: 'owner-1', username: '观云者' },
|
|
cloud_type: { id: 1, name: '积云', genus: null, icon_id: null, rarity: 2, description: '' },
|
|
latitude: 0,
|
|
longitude: 0,
|
|
description: '',
|
|
captured_at: null,
|
|
uploaded_at: '2026-09-30T00:00:00.000Z',
|
|
updated_at: '2026-09-30T00:00:00.000Z',
|
|
received_like_count: 3,
|
|
review_status: 'approved',
|
|
is_hidden: false,
|
|
}
|
|
|
|
test('cloud API encodes search once and maps the server page into view models', async () => {
|
|
mock.method(globalThis, 'fetch', async (url, options) => {
|
|
const parsed = new URL(url)
|
|
assert.equal(parsed.pathname, '/clouds')
|
|
assert.equal(parsed.searchParams.get('value'), '@云 & +')
|
|
assert.equal(parsed.searchParams.has('filter'), false)
|
|
assert.equal(options.credentials, 'include')
|
|
return Response.json({ items: [cloud], page: 1, page_size: 1, has_more: false })
|
|
})
|
|
const page = await listClouds({ page: 1, page_size: 1, value: '@云 & +' })
|
|
assert.equal(page.has_more, false)
|
|
assert.equal(page.items[0].status, 'approved')
|
|
assert.equal(page.items[0].username, '观云者')
|
|
assert.equal(page.items[0].cloud_type_id, 1)
|
|
assert.match(page.items[0].image_url, /\/images\/cloud-1\/original$/)
|
|
})
|
|
|
|
test('JSON, typed arrays and multipart preserve their distinct encodings', async () => {
|
|
const calls = []
|
|
mock.method(globalThis, 'fetch', async (url, options) => {
|
|
calls.push({ url, ...options })
|
|
return Response.json({ message: '成功', cloud_id: 'new' }, { status: 201 })
|
|
})
|
|
await apiRequest('/example', { method: 'PATCH', body: { is_hidden: false } })
|
|
assert.equal(calls[0].headers.get('content-type'), 'application/json')
|
|
assert.equal(calls[0].body, '{"is_hidden":false}')
|
|
const bytes = new Uint8Array([1, 2])
|
|
await apiRequest('/example', { method: 'POST', body: bytes })
|
|
assert.equal(calls[1].body, bytes)
|
|
const file = new File(['image'], 'cloud.png', { type: 'image/png' })
|
|
await uploadCloud({
|
|
image: file,
|
|
cloud_type_id: 1,
|
|
latitude: 0,
|
|
longitude: 0,
|
|
is_hidden: false,
|
|
})
|
|
assert.equal(calls[2].headers.has('content-type'), false)
|
|
assert.equal(calls[2].body.get('is_hidden'), 'false')
|
|
assert.equal(calls[2].body.get('cloud_type_id'), '1')
|
|
assert.equal(calls[2].body.has('type_id'), false)
|
|
assert.equal(calls[2].body.get('image').name, file.name)
|
|
})
|
|
|
|
test('errors retain status and validation issues; invalid success responses fail early', async () => {
|
|
const payload = { message: '用户名已被占用', issues: [{ path: 'username', message: '重复' }] }
|
|
mock.method(globalThis, 'fetch', async () => Response.json(payload, { status: 409 }))
|
|
await assert.rejects(apiRequest('/profiles/me'), error => {
|
|
assert.ok(error instanceof ApiError)
|
|
assert.equal(error.status, 409)
|
|
assert.equal(error.message, payload.message)
|
|
assert.deepEqual(error.detail, payload)
|
|
return true
|
|
})
|
|
globalThis.fetch.mock.mockImplementation(async () => new Response('<html>gateway</html>'))
|
|
await assert.rejects(apiRequest('/clouds'), /无效的 JSON/)
|
|
globalThis.fetch.mock.mockImplementation(
|
|
async () => new Response('{oops', { headers: { 'content-type': 'application/json' } }),
|
|
)
|
|
await assert.rejects(apiRequest('/clouds'), /无效的 JSON/)
|
|
globalThis.fetch.mock.mockImplementation(async () => new Response(null, { status: 204 }))
|
|
assert.equal(await apiRequest('/clouds'), undefined)
|
|
})
|
|
|
|
test('401 expires required sessions only; transport never retries a mutation', async () => {
|
|
const events = new EventTarget()
|
|
let expired = 0
|
|
events.addEventListener(AUTH_EXPIRED_EVENT, () => expired++)
|
|
globalThis.window = events
|
|
try {
|
|
const fetch = mock.method(globalThis, 'fetch', async () =>
|
|
Response.json({ message: '失效' }, { status: 401 }),
|
|
)
|
|
await assert.rejects(apiRequest('/clouds', { method: 'POST', body: {} }))
|
|
assert.equal(expired, 1)
|
|
await assert.rejects(apiRequest('/clouds', { auth: false }))
|
|
assert.equal(expired, 1)
|
|
assert.equal(fetch.mock.callCount(), 2)
|
|
} finally {
|
|
delete globalThis.window
|
|
}
|
|
})
|
|
|
|
test('pagination follows explicit has_more even on a full final page', async () => {
|
|
const pages = []
|
|
const result = await collectPages(async page => {
|
|
pages.push(page)
|
|
return { items: [page], page, page_size: 1, has_more: page < 2 }
|
|
})
|
|
assert.deepEqual(result, [1, 2])
|
|
assert.deepEqual(pages, [1, 2])
|
|
})
|
|
|
|
test('batch deletion reports completed batches when a later request fails', async () => {
|
|
const completed = []
|
|
let calls = 0
|
|
mock.method(globalThis, 'fetch', async (_url, options) => {
|
|
const ids = JSON.parse(options.body).cloud_ids
|
|
assert.ok(ids.length <= 100)
|
|
assert.ok(ids.every(id => typeof id === 'string'))
|
|
return ++calls === 1
|
|
? Response.json({ message: '完成' })
|
|
: Response.json({ message: '失败' }, { status: 500 })
|
|
})
|
|
const ids = Array.from({ length: 101 }, (_, index) => String(index))
|
|
await assert.rejects(
|
|
deleteClouds(ids, batch => completed.push(...batch)),
|
|
/失败/,
|
|
)
|
|
assert.deepEqual(completed, ids.slice(0, 100))
|
|
})
|