docs: 交付文档、维护指引与端到端回归
补齐 README、开发计划、维护交接与 E2E 用例,执行最终验收。
This commit is contained in:
1 parent
5be5ca3376
commit
308332eb94
216 files changed
+37038
No files matched your search
@@ -0,0 +1,239 @@
|
||||
/*
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { AxiosError } from 'axios'
|
||||
import type { AxiosAdapter, AxiosRequestConfig } from 'axios'
|
||||
|
||||
// The 401 handling below is the app's whole session-recovery story, and it
|
||||
// leans on axios internals: the error carries the original request config, the
|
||||
// config survives a retry, and our own `_retry` flag rides along on it. Those
|
||||
// are exactly the parts an axios upgrade can move, so they are pinned here.
|
||||
|
||||
const push = vi.fn()
|
||||
vi.mock('@/router', () => ({ default: { push } }))
|
||||
|
||||
const isAuthenticated = vi.fn()
|
||||
const clearCurrentUser = vi.fn()
|
||||
vi.mock('@/services/user', () => ({
|
||||
userService: {
|
||||
isAuthenticated: () => isAuthenticated(),
|
||||
clearCurrentUser: () => clearCurrentUser(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/config/api', () => ({ getApiUrl: () => 'http://api.test/api/v1' }))
|
||||
|
||||
vi.mock('@/composables/useEnterpriseFeatures', () => ({
|
||||
useEnterpriseFeatures: () => ({
|
||||
hasEnterpriseModule: false,
|
||||
loadModule: vi.fn(),
|
||||
moduleImports: {},
|
||||
}),
|
||||
}))
|
||||
|
||||
/** An adapter that answers each call with the next queued status.
|
||||
*
|
||||
* A custom adapter has to settle the response itself — axios only rejects
|
||||
* non-2xx replies from inside its own adapters — so this raises the same
|
||||
* AxiosError shape (config + response attached) the real ones do.
|
||||
*/
|
||||
function adapterQueue(statuses: number[]) {
|
||||
const seen: AxiosRequestConfig[] = []
|
||||
const adapter: AxiosAdapter = async (config) => {
|
||||
seen.push(config)
|
||||
const status = statuses.shift() ?? 200
|
||||
const response = {
|
||||
data: { status },
|
||||
status,
|
||||
statusText: String(status),
|
||||
headers: {},
|
||||
config,
|
||||
}
|
||||
if (status >= 200 && status < 300) return response
|
||||
throw new AxiosError(
|
||||
`Request failed with status code ${status}`,
|
||||
AxiosError.ERR_BAD_REQUEST,
|
||||
config,
|
||||
{},
|
||||
response,
|
||||
)
|
||||
}
|
||||
return { adapter, seen }
|
||||
}
|
||||
|
||||
/** The refresh call answered with `status`, as axios rejects it. */
|
||||
function refreshFailure(status: number) {
|
||||
return new AxiosError(`Request failed with status code ${status}`, AxiosError.ERR_BAD_RESPONSE, {} as never, {}, {
|
||||
data: {}, status, statusText: String(status), headers: {}, config: {} as never,
|
||||
})
|
||||
}
|
||||
|
||||
async function loadApi() {
|
||||
vi.resetModules()
|
||||
const [{ default: api }, axiosModule] = await Promise.all([
|
||||
import('@/services/api'),
|
||||
import('axios'),
|
||||
])
|
||||
return { api, axios: axiosModule.default }
|
||||
}
|
||||
|
||||
describe('api client', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
isAuthenticated.mockReturnValue(true)
|
||||
document.cookie = 'user_info=someone'
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('sends requests to the configured API base URL', async () => {
|
||||
const { api } = await loadApi()
|
||||
const { adapter, seen } = adapterQueue([200])
|
||||
|
||||
await api.get('/agents', { adapter })
|
||||
|
||||
expect(seen).toHaveLength(1)
|
||||
expect(seen[0].baseURL).toBe('http://api.test/api/v1')
|
||||
expect(seen[0].withCredentials).toBe(true)
|
||||
})
|
||||
|
||||
it('does not attach a bearer token outside the Shopify embedded context', async () => {
|
||||
const { api } = await loadApi()
|
||||
const { adapter, seen } = adapterQueue([200])
|
||||
|
||||
await api.get('/chats/1', { adapter })
|
||||
|
||||
expect(seen[0].headers?.Authorization).toBeUndefined()
|
||||
})
|
||||
|
||||
it('refreshes the session once on a 401 and replays the request', async () => {
|
||||
const { api, axios } = await loadApi()
|
||||
const refresh = vi.spyOn(axios, 'post').mockResolvedValue({ data: {} } as never)
|
||||
const { adapter, seen } = adapterQueue([401, 200])
|
||||
|
||||
const response = await api.get('/agents', { adapter })
|
||||
|
||||
expect(refresh).toHaveBeenCalledTimes(1)
|
||||
expect(refresh.mock.calls[0][0]).toBe('/users/refresh')
|
||||
expect(response.status).toBe(200)
|
||||
expect(seen).toHaveLength(2)
|
||||
expect(seen[1]._retry).toBe(true)
|
||||
})
|
||||
|
||||
it('does not loop when the replayed request is also rejected', async () => {
|
||||
const { api, axios } = await loadApi()
|
||||
const refresh = vi.spyOn(axios, 'post').mockResolvedValue({ data: {} } as never)
|
||||
const { adapter, seen } = adapterQueue([401, 401])
|
||||
|
||||
await expect(api.get('/agents', { adapter })).rejects.toMatchObject({
|
||||
response: { status: 401 },
|
||||
})
|
||||
|
||||
expect(refresh).toHaveBeenCalledTimes(1)
|
||||
expect(seen).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('signs the user out when the refresh says the session is over', async () => {
|
||||
const { api, axios } = await loadApi()
|
||||
vi.spyOn(axios, 'post').mockRejectedValue(refreshFailure(401))
|
||||
const { adapter } = adapterQueue([401])
|
||||
|
||||
await expect(api.get('/agents', { adapter })).rejects.toMatchObject({ response: { status: 401 } })
|
||||
|
||||
expect(clearCurrentUser).toHaveBeenCalled()
|
||||
expect(push).toHaveBeenCalledWith('/login')
|
||||
expect(document.cookie).not.toContain('user_info=someone')
|
||||
})
|
||||
|
||||
// Seen in production: a deploy's restart (502), the API rate limit (503) and
|
||||
// a dropped connection each signed people out mid-session.
|
||||
it.each([
|
||||
['the API restarting on a deploy', refreshFailure(502)],
|
||||
['the rate limit', refreshFailure(503)],
|
||||
['a dropped connection', new AxiosError('Network Error', AxiosError.ERR_NETWORK)],
|
||||
])('keeps the user signed in when the refresh fails from %s', async (_, failure) => {
|
||||
const { api, axios } = await loadApi()
|
||||
vi.spyOn(axios, 'post').mockRejectedValue(failure)
|
||||
const { adapter } = adapterQueue([401])
|
||||
|
||||
await expect(api.get('/agents', { adapter })).rejects.toBe(failure)
|
||||
|
||||
expect(clearCurrentUser).not.toHaveBeenCalled()
|
||||
expect(push).not.toHaveBeenCalled()
|
||||
expect(document.cookie).toContain('user_info=someone')
|
||||
})
|
||||
|
||||
it('refreshes once for a burst of 401s, and replays every request', async () => {
|
||||
const { api, axios } = await loadApi()
|
||||
let finishRefresh: (value: unknown) => void = () => {}
|
||||
const refresh = vi.spyOn(axios, 'post').mockImplementation(
|
||||
() => new Promise((resolve) => { finishRefresh = resolve }) as never,
|
||||
)
|
||||
const { adapter, seen } = adapterQueue([401, 401, 401, 401, 401, 200, 200, 200, 200, 200])
|
||||
|
||||
const burst = Promise.all(Array.from({ length: 5 }, (_, i) => api.get(`/page-${i}`, { adapter })))
|
||||
await vi.waitFor(() => expect(seen).toHaveLength(5))
|
||||
finishRefresh({ data: {} })
|
||||
const responses = await burst
|
||||
|
||||
expect(refresh).toHaveBeenCalledTimes(1)
|
||||
expect(responses.map((r) => r.status)).toEqual([200, 200, 200, 200, 200])
|
||||
expect(seen).toHaveLength(10)
|
||||
})
|
||||
|
||||
it('refreshes again for a later 401, once the first refresh is over', async () => {
|
||||
const { api, axios } = await loadApi()
|
||||
const refresh = vi.spyOn(axios, 'post').mockResolvedValue({ data: {} } as never)
|
||||
const { adapter } = adapterQueue([401, 200, 401, 200])
|
||||
|
||||
await api.get('/agents', { adapter })
|
||||
await api.get('/agents', { adapter })
|
||||
|
||||
expect(refresh).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('goes straight to login on a 401 when nobody is signed in', async () => {
|
||||
isAuthenticated.mockReturnValue(false)
|
||||
const { api, axios } = await loadApi()
|
||||
const refresh = vi.spyOn(axios, 'post')
|
||||
const { adapter, seen } = adapterQueue([401])
|
||||
|
||||
await expect(api.get('/agents', { adapter })).rejects.toMatchObject({
|
||||
response: { status: 401 },
|
||||
})
|
||||
|
||||
expect(refresh).not.toHaveBeenCalled()
|
||||
expect(seen).toHaveLength(1)
|
||||
expect(push).toHaveBeenCalledWith('/login')
|
||||
})
|
||||
|
||||
it('passes other error statuses through untouched', async () => {
|
||||
const { api, axios } = await loadApi()
|
||||
const refresh = vi.spyOn(axios, 'post')
|
||||
const { adapter, seen } = adapterQueue([500])
|
||||
|
||||
await expect(api.get('/agents', { adapter })).rejects.toMatchObject({
|
||||
response: { status: 500 },
|
||||
})
|
||||
|
||||
expect(refresh).not.toHaveBeenCalled()
|
||||
expect(seen).toHaveLength(1)
|
||||
expect(push).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user