fix: 补全公司切换的保存互斥与旧读请求中止
- 保存请求执行期间禁止切换:organization store 新增 saving/busy 与 beginSaving/endSaving,切换守卫与切换器禁用条件均由 switching 改为 busy - 切换提交时 abort 旧读请求:commitOrganizationId 内调用 abortReadRequests, 不再只在 resetOrganizationContext 里取消 - validateCandidate 仅将 403 视为候选失效,网络异常/500 等向上抛出; initialize 置 loadError,App 据此区分“加载失败”与“请先选择公司”并支持重试 - 补竞态与中止测试;需要写请求时旧响应仍被版本丢弃的用例改用 POST 覆盖
This commit is contained in:
parent
6cc3cd5dda
commit
ffcf7748fb
|
|
@ -7,7 +7,12 @@
|
||||||
<main class="app-main">
|
<main class="app-main">
|
||||||
<!-- 公司尚未确定时用屏障占位,不挂载业务页面,避免组件 onMounted 发出缺少组织头的请求 -->
|
<!-- 公司尚未确定时用屏障占位,不挂载业务页面,避免组件 onMounted 发出缺少组织头的请求 -->
|
||||||
<div v-if="organizationBarrier" class="organization-barrier">
|
<div v-if="organizationBarrier" class="organization-barrier">
|
||||||
<el-empty description="请先选择要操作的公司" />
|
<el-empty v-if="organizationStore.loadError" description="公司信息加载失败,请重试">
|
||||||
|
<el-button type="primary" :loading="retrying" @click="retryInitialize">
|
||||||
|
重新加载
|
||||||
|
</el-button>
|
||||||
|
</el-empty>
|
||||||
|
<el-empty v-else description="请先选择要操作的公司" />
|
||||||
</div>
|
</div>
|
||||||
<router-view v-else :key="viewKey" />
|
<router-view v-else :key="viewKey" />
|
||||||
</main>
|
</main>
|
||||||
|
|
@ -20,7 +25,7 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { useAuthStore } from './stores/auth'
|
import { useAuthStore } from './stores/auth'
|
||||||
import { useOrganizationStore } from './stores/organization'
|
import { useOrganizationStore } from './stores/organization'
|
||||||
|
|
@ -32,6 +37,9 @@ const router = useRouter()
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
const organizationStore = useOrganizationStore()
|
const organizationStore = useOrganizationStore()
|
||||||
|
|
||||||
|
// 加载失败后用户手动重试;重试本身不应被误认为公司切换,故用独立标记。
|
||||||
|
const retrying = ref(false)
|
||||||
|
|
||||||
const currentRoute = computed(() => route.path)
|
const currentRoute = computed(() => route.path)
|
||||||
|
|
||||||
// 组织级页面在公司未确定时不挂载:由 AppHeader 的切换器引导用户先选公司。
|
// 组织级页面在公司未确定时不挂载:由 AppHeader 的切换器引导用户先选公司。
|
||||||
|
|
@ -49,6 +57,19 @@ const viewKey = computed(() => {
|
||||||
function handleMenuSelect(path) {
|
function handleMenuSelect(path) {
|
||||||
router.push(path)
|
router.push(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 手动重试公司初始化:成功后 currentOrganizationId 变为非空,屏障自动撤下;
|
||||||
|
// 仍失败时保持“加载失败”提示,等待用户再次重试。
|
||||||
|
async function retryInitialize() {
|
||||||
|
retrying.value = true
|
||||||
|
try {
|
||||||
|
await organizationStore.initialize()
|
||||||
|
} catch {
|
||||||
|
// 错误已由 axios 拦截器提示,且 store 已置 loadError,这里不再重复弹提示。
|
||||||
|
} finally {
|
||||||
|
retrying.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,8 @@ vi.mock('../../stores/organization', async () => {
|
||||||
currentOrganizationId: null,
|
currentOrganizationId: null,
|
||||||
currentWarehouse: null,
|
currentWarehouse: null,
|
||||||
switching: false,
|
switching: false,
|
||||||
|
saving: false,
|
||||||
|
busy: false,
|
||||||
switchOrganization: vi.fn()
|
switchOrganization: vi.fn()
|
||||||
})
|
})
|
||||||
return { useOrganizationStore: () => state }
|
return { useOrganizationStore: () => state }
|
||||||
|
|
@ -47,6 +49,8 @@ describe('OrganizationSwitcher 公司切换器', () => {
|
||||||
store.currentOrganizationId = null
|
store.currentOrganizationId = null
|
||||||
store.currentWarehouse = null
|
store.currentWarehouse = null
|
||||||
store.switching = false
|
store.switching = false
|
||||||
|
store.saving = false
|
||||||
|
store.busy = false
|
||||||
store.switchOrganization.mockReset()
|
store.switchOrganization.mockReset()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -75,6 +79,17 @@ describe('OrganizationSwitcher 公司切换器', () => {
|
||||||
test('切换进行中下拉禁用', () => {
|
test('切换进行中下拉禁用', () => {
|
||||||
store.organizations = [{ id: 1, name: '北京' }]
|
store.organizations = [{ id: 1, name: '北京' }]
|
||||||
store.switching = true
|
store.switching = true
|
||||||
|
store.busy = true
|
||||||
|
|
||||||
|
const wrapper = mountSwitcher()
|
||||||
|
|
||||||
|
expect(wrapper.findComponent(SelectStub).props('disabled')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('保存请求进行中下拉禁用', () => {
|
||||||
|
store.organizations = [{ id: 1, name: '北京' }]
|
||||||
|
store.saving = true
|
||||||
|
store.busy = true
|
||||||
|
|
||||||
const wrapper = mountSwitcher()
|
const wrapper = mountSwitcher()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -29,9 +29,9 @@ const placeholder = computed(() =>
|
||||||
organizationStore.organizations.length === 0 ? '暂无可用公司' : '请选择公司'
|
organizationStore.organizations.length === 0 ? '暂无可用公司' : '请选择公司'
|
||||||
)
|
)
|
||||||
|
|
||||||
// 没有授权公司或正在切换时禁止操作,避免空选择和无意义的并发切换。
|
// 没有授权公司、正在切换或保存请求进行中时禁止操作:保存飞行中切换会让保存结果被静默丢弃。
|
||||||
const selectDisabled = computed(
|
const selectDisabled = computed(
|
||||||
() => organizationStore.switching || organizationStore.organizations.length === 0
|
() => organizationStore.busy || organizationStore.organizations.length === 0
|
||||||
)
|
)
|
||||||
|
|
||||||
// 仓库、库位摘要直接来自服务端返回,普通用户无需调用管理员仓库接口。
|
// 仓库、库位摘要直接来自服务端返回,普通用户无需调用管理员仓库接口。
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { authApi } from '../api/auth'
|
import { authApi } from '../api/auth'
|
||||||
import {
|
import {
|
||||||
captureOrganizationContext,
|
captureOrganizationContext,
|
||||||
|
|
@ -42,8 +42,17 @@ export const useOrganizationStore = defineStore('organization', () => {
|
||||||
const currentWarehouse = ref(null)
|
const currentWarehouse = ref(null)
|
||||||
const contextVersion = ref(0)
|
const contextVersion = ref(0)
|
||||||
const switching = ref(false)
|
const switching = ref(false)
|
||||||
|
// 写请求(保存)进行中标记:业务 store 在发起保存前 beginSaving()、结束后 endSaving()。
|
||||||
|
// 保存飞行中若切换公司,保存的成功/失败响应会因版本变化被视为作废并静默丢弃,
|
||||||
|
// 用户既看不到成功也看不到失败,所以保存期间必须禁止切换。
|
||||||
|
const saving = ref(false)
|
||||||
|
// 任何会导致“公司上下文变更或归属不明”的操作进行中都算忙:切换中或保存中。
|
||||||
|
const busy = computed(() => switching.value || saving.value)
|
||||||
// 是否已完成一次公司列表加载,路由屏障据此决定是否需要先初始化。
|
// 是否已完成一次公司列表加载,路由屏障据此决定是否需要先初始化。
|
||||||
const initialized = ref(false)
|
const initialized = ref(false)
|
||||||
|
// 最近一次 initialize 是否因临时故障失败:用于把“加载失败”与“确实没有可用公司”区分开,
|
||||||
|
// 避免把网络异常误导成“请先选择要操作的公司”。
|
||||||
|
const loadError = ref(false)
|
||||||
|
|
||||||
// 把 store 内展示用的版本号同步成上下文工具里的真实版本,两者始终一致。
|
// 把 store 内展示用的版本号同步成上下文工具里的真实版本,两者始终一致。
|
||||||
function syncVersion() {
|
function syncVersion() {
|
||||||
|
|
@ -91,46 +100,67 @@ export const useOrganizationStore = defineStore('organization', () => {
|
||||||
const res = await authApi.switchOrganizationContext(organizationId, getOrganizationId() ?? 0)
|
const res = await authApi.switchOrganizationContext(organizationId, getOrganizationId() ?? 0)
|
||||||
applyContext(res.data || {})
|
applyContext(res.data || {})
|
||||||
return true
|
return true
|
||||||
} catch {
|
} catch (error) {
|
||||||
// 候选失效交给调用方继续尝试下一个;这里不弹提示,错误由拦截器统一处理。
|
// 只有 403(该用户对该候选公司确实无授权)才算“候选失效”,返回 false 交给调用方尝试下一个。
|
||||||
return false
|
if (error?.response?.status === 403) return false
|
||||||
|
// 网络异常、500 等临时故障必须向上抛出:若被当成候选失效,initialize 会落入空状态并
|
||||||
|
// 提示“请先选择要操作的公司”,把一次性故障误导成需要重新选公司。
|
||||||
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function initialize() {
|
async function initialize() {
|
||||||
const res = await authApi.listOrganizations()
|
loadError.value = false
|
||||||
const data = res.data || {}
|
try {
|
||||||
organizations.value = data.organizations || []
|
const res = await authApi.listOrganizations()
|
||||||
|
const data = res.data || {}
|
||||||
|
organizations.value = data.organizations || []
|
||||||
|
|
||||||
if (organizations.value.length === 0) {
|
if (organizations.value.length === 0) {
|
||||||
// 无授权公司:空状态,不发起任何校验请求。
|
// 无授权公司:空状态,不发起任何校验请求。
|
||||||
applyEmptyContext()
|
applyEmptyContext()
|
||||||
initialized.value = true
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 本标签页上次选择优先,其次默认公司;两者都必须重新校验授权。
|
|
||||||
const defaultId = data.default_organization_id ?? null
|
|
||||||
const candidates = [readStoredOrganizationId(), defaultId]
|
|
||||||
const seen = new Set()
|
|
||||||
for (const candidate of candidates) {
|
|
||||||
if (candidate == null || seen.has(candidate)) continue
|
|
||||||
seen.add(candidate)
|
|
||||||
if (!organizations.value.some(org => org.id === candidate)) continue
|
|
||||||
if (await validateCandidate(candidate)) {
|
|
||||||
initialized.value = true
|
initialized.value = true
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// 候选都失效但仍有授权公司:保留列表,要求用户重新选择。
|
// 本标签页上次选择优先,其次默认公司;两者都必须重新校验授权。
|
||||||
applyEmptyContext()
|
const defaultId = data.default_organization_id ?? null
|
||||||
initialized.value = true
|
const candidates = [readStoredOrganizationId(), defaultId]
|
||||||
|
const seen = new Set()
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (candidate == null || seen.has(candidate)) continue
|
||||||
|
seen.add(candidate)
|
||||||
|
if (!organizations.value.some(org => org.id === candidate)) continue
|
||||||
|
if (await validateCandidate(candidate)) {
|
||||||
|
initialized.value = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 候选都失效但仍有授权公司:保留列表,要求用户重新选择。
|
||||||
|
applyEmptyContext()
|
||||||
|
initialized.value = true
|
||||||
|
} catch (error) {
|
||||||
|
// 临时故障(列表拉取失败或候选校验非 403)不降级成“无公司”空状态:
|
||||||
|
// 标记加载失败供 App 显示“加载失败”而非“请先选择公司”,并向上抛出交由调用方重试。
|
||||||
|
loadError.value = true
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 业务 store 复用的“写请求进行中”开关:发起保存前置位、结束后复位(务必放在 finally)。
|
||||||
|
function beginSaving() {
|
||||||
|
saving.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function endSaving() {
|
||||||
|
saving.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
async function switchOrganization(organizationId) {
|
async function switchOrganization(organizationId) {
|
||||||
// 切换或保存进行中禁止再次切换:避免并发切换互相覆盖,以及写请求归属不明确。
|
// 切换或保存进行中禁止再次切换:避免并发切换互相覆盖,以及写请求归属不明确。
|
||||||
if (switching.value) return
|
// 保存飞行中切换会让保存的响应被版本判定作废并被静默吞掉,用户既看不到成功也看不到失败。
|
||||||
|
if (busy.value) return
|
||||||
if (!organizationId || organizationId === currentOrganizationId.value) return
|
if (!organizationId || organizationId === currentOrganizationId.value) return
|
||||||
switching.value = true
|
switching.value = true
|
||||||
try {
|
try {
|
||||||
|
|
@ -154,13 +184,16 @@ export const useOrganizationStore = defineStore('organization', () => {
|
||||||
currentWarehouseId.value = null
|
currentWarehouseId.value = null
|
||||||
currentLocationId.value = null
|
currentLocationId.value = null
|
||||||
switching.value = false
|
switching.value = false
|
||||||
|
saving.value = false
|
||||||
initialized.value = false
|
initialized.value = false
|
||||||
|
loadError.value = false
|
||||||
syncVersion()
|
syncVersion()
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
organizations, currentOrganizationId, currentWarehouseId, currentLocationId,
|
organizations, currentOrganizationId, currentWarehouseId, currentLocationId,
|
||||||
currentWarehouse, contextVersion, switching, initialized,
|
currentWarehouse, contextVersion, switching, saving, busy, initialized, loadError,
|
||||||
|
beginSaving, endSaving,
|
||||||
initialize, switchOrganization, reset
|
initialize, switchOrganization, reset
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { createPinia, setActivePinia } from 'pinia'
|
||||||
import { beforeEach, describe, expect, test, vi } from 'vitest'
|
import { beforeEach, describe, expect, test, vi } from 'vitest'
|
||||||
import { authApi } from '../api/auth'
|
import { authApi } from '../api/auth'
|
||||||
import { useOrganizationStore } from './organization'
|
import { useOrganizationStore } from './organization'
|
||||||
import { resetOrganizationContext } from '../utils/organizationContext'
|
import { currentReadSignal, resetOrganizationContext } from '../utils/organizationContext'
|
||||||
|
|
||||||
vi.mock('../api/auth', () => ({
|
vi.mock('../api/auth', () => ({
|
||||||
authApi: {
|
authApi: {
|
||||||
|
|
@ -72,7 +72,8 @@ describe('organization store', () => {
|
||||||
test('默认公司失效时保留列表并要求重新选择', async () => {
|
test('默认公司失效时保留列表并要求重新选择', async () => {
|
||||||
loginAs(1)
|
loginAs(1)
|
||||||
authApi.listOrganizations.mockResolvedValue(orgList([{ id: 1, name: '北京' }], 99))
|
authApi.listOrganizations.mockResolvedValue(orgList([{ id: 1, name: '北京' }], 99))
|
||||||
authApi.switchOrganizationContext.mockRejectedValue(new Error('公司不可访问'))
|
// 候选公司确实无授权时服务端返回 403,才判定为“失效”。
|
||||||
|
authApi.switchOrganizationContext.mockRejectedValue({ response: { status: 403 } })
|
||||||
|
|
||||||
const store = useOrganizationStore()
|
const store = useOrganizationStore()
|
||||||
await store.initialize()
|
await store.initialize()
|
||||||
|
|
@ -82,6 +83,28 @@ describe('organization store', () => {
|
||||||
expect(store.initialized).toBe(true)
|
expect(store.initialized).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('候选校验遇到非 403 的临时故障时向上抛出,不误报空状态', async () => {
|
||||||
|
loginAs(1)
|
||||||
|
authApi.listOrganizations.mockResolvedValue(orgList([{ id: 1, name: '北京' }], 1))
|
||||||
|
// 网络异常/500 没有 response.status===403,不能被当成候选失效。
|
||||||
|
authApi.switchOrganizationContext.mockRejectedValue(new Error('网络异常'))
|
||||||
|
|
||||||
|
const store = useOrganizationStore()
|
||||||
|
|
||||||
|
await expect(store.initialize()).rejects.toThrow('网络异常')
|
||||||
|
// 不进入空状态:标记加载失败、保留未初始化,供 App 区分“加载失败”并允许重试。
|
||||||
|
expect(store.currentOrganizationId).toBeNull()
|
||||||
|
expect(store.initialized).toBe(false)
|
||||||
|
expect(store.loadError).toBe(true)
|
||||||
|
|
||||||
|
// 故障恢复后重试成功,加载失败标记清除。
|
||||||
|
authApi.switchOrganizationContext.mockResolvedValueOnce(switchResult(1))
|
||||||
|
await store.initialize()
|
||||||
|
expect(store.initialized).toBe(true)
|
||||||
|
expect(store.loadError).toBe(false)
|
||||||
|
expect(store.currentOrganizationId).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
test('没有授权公司时进入空状态且不发起校验', async () => {
|
test('没有授权公司时进入空状态且不发起校验', async () => {
|
||||||
loginAs(1)
|
loginAs(1)
|
||||||
authApi.listOrganizations.mockResolvedValue(orgList([], null))
|
authApi.listOrganizations.mockResolvedValue(orgList([], null))
|
||||||
|
|
@ -222,6 +245,50 @@ describe('organization store', () => {
|
||||||
expect(store.currentOrganizationId).toBe(1)
|
expect(store.currentOrganizationId).toBe(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('写请求未完成时切换被拒绝', async () => {
|
||||||
|
loginAs(1)
|
||||||
|
authApi.listOrganizations.mockResolvedValue(orgList([{ id: 1 }, { id: 2 }], 1))
|
||||||
|
authApi.switchOrganizationContext.mockResolvedValueOnce(switchResult(1))
|
||||||
|
const store = useOrganizationStore()
|
||||||
|
await store.initialize()
|
||||||
|
authApi.switchOrganizationContext.mockClear()
|
||||||
|
|
||||||
|
// 模拟某个业务 store 的保存请求仍在飞行中。
|
||||||
|
store.beginSaving()
|
||||||
|
expect(store.busy).toBe(true)
|
||||||
|
|
||||||
|
await store.switchOrganization(2)
|
||||||
|
|
||||||
|
// 保存期间切换会让保存响应被版本判定作废并静默丢弃,必须直接拒绝且不发起校验。
|
||||||
|
expect(authApi.switchOrganizationContext).not.toHaveBeenCalled()
|
||||||
|
expect(store.currentOrganizationId).toBe(1)
|
||||||
|
|
||||||
|
// 保存结束后重新允许切换,避免忙标志被永久卡住。
|
||||||
|
store.endSaving()
|
||||||
|
expect(store.busy).toBe(false)
|
||||||
|
authApi.switchOrganizationContext.mockResolvedValueOnce(switchResult(2))
|
||||||
|
await store.switchOrganization(2)
|
||||||
|
expect(store.currentOrganizationId).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('切换公司会中止旧公司尚未返回的读请求', async () => {
|
||||||
|
loginAs(1)
|
||||||
|
authApi.listOrganizations.mockResolvedValue(orgList([{ id: 1 }, { id: 2 }], 1))
|
||||||
|
authApi.switchOrganizationContext.mockResolvedValueOnce(switchResult(1))
|
||||||
|
const store = useOrganizationStore()
|
||||||
|
await store.initialize()
|
||||||
|
|
||||||
|
// 切换前取到的读请求信号,代表旧公司尚未返回的读请求。
|
||||||
|
const oldSignal = currentReadSignal()
|
||||||
|
expect(oldSignal.aborted).toBe(false)
|
||||||
|
|
||||||
|
authApi.switchOrganizationContext.mockResolvedValueOnce(switchResult(2))
|
||||||
|
await store.switchOrganization(2)
|
||||||
|
|
||||||
|
expect(oldSignal.aborted).toBe(true)
|
||||||
|
expect(store.currentOrganizationId).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
test('reset 清空公司状态与上下文', async () => {
|
test('reset 清空公司状态与上下文', async () => {
|
||||||
loginAs(1)
|
loginAs(1)
|
||||||
authApi.listOrganizations.mockResolvedValue(orgList([{ id: 1 }], 1))
|
authApi.listOrganizations.mockResolvedValue(orgList([{ id: 1 }], 1))
|
||||||
|
|
|
||||||
|
|
@ -13,9 +13,12 @@ export function getOrganizationId() {
|
||||||
|
|
||||||
// 提交新的公司上下文:写入公司 ID 并递增版本。旧响应靠版本号即可识别并丢弃,
|
// 提交新的公司上下文:写入公司 ID 并递增版本。旧响应靠版本号即可识别并丢弃,
|
||||||
// 因为 A→B→A 时公司 ID 又变回相同值,仅比较 ID 无法区分“第一次 A”的响应。
|
// 因为 A→B→A 时公司 ID 又变回相同值,仅比较 ID 无法区分“第一次 A”的响应。
|
||||||
|
// 提交新上下文同时取消旧公司尚未返回的读请求:版本判定只能丢弃迟到的响应,
|
||||||
|
// 真正中止在途读请求要靠 AbortController,避免旧公司的数据在切换后继续占用连接。
|
||||||
export function commitOrganizationId(organizationId) {
|
export function commitOrganizationId(organizationId) {
|
||||||
currentOrganizationId = organizationId ?? null
|
currentOrganizationId = organizationId ?? null
|
||||||
currentVersion += 1
|
currentVersion += 1
|
||||||
|
abortReadRequests()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 捕获发起请求那一刻的上下文快照,响应回来时用它判断是否已被切换作废。
|
// 捕获发起请求那一刻的上下文快照,响应回来时用它判断是否已被切换作废。
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import axios from 'axios'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
|
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
|
||||||
import request from '../api/index'
|
import request from '../api/index'
|
||||||
|
|
@ -57,6 +58,15 @@ describe('公司上下文快照', () => {
|
||||||
expect(newSignal).not.toBe(oldSignal)
|
expect(newSignal).not.toBe(oldSignal)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('提交公司上下文即中止旧读请求', () => {
|
||||||
|
const oldSignal = currentReadSignal()
|
||||||
|
|
||||||
|
commitOrganizationId(2)
|
||||||
|
|
||||||
|
expect(oldSignal.aborted).toBe(true)
|
||||||
|
expect(currentReadSignal().aborted).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
test('作废错误可被识别,区别于普通失败', () => {
|
test('作废错误可被识别,区别于普通失败', () => {
|
||||||
expect(isOrganizationContextSuperseded(new OrganizationContextSupersededError())).toBe(true)
|
expect(isOrganizationContextSuperseded(new OrganizationContextSupersededError())).toBe(true)
|
||||||
expect(isOrganizationContextSuperseded(new Error('普通错误'))).toBe(false)
|
expect(isOrganizationContextSuperseded(new Error('普通错误'))).toBe(false)
|
||||||
|
|
@ -107,6 +117,8 @@ describe('axios 组织级请求拦截', () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
test('切换公司后旧成功响应被丢弃且不弹提示', async () => {
|
test('切换公司后旧成功响应被丢弃且不弹提示', async () => {
|
||||||
|
// 用 POST(写请求)验证版本判定这条兜底:写请求不挂共享读信号,切换不会把它 abort,
|
||||||
|
// 因此旧响应仍会到达响应拦截器,必须靠上下文版本识别并丢弃。
|
||||||
commitOrganizationId(1)
|
commitOrganizationId(1)
|
||||||
const errorSpy = vi.spyOn(ElMessage, 'error').mockImplementation(() => {})
|
const errorSpy = vi.spyOn(ElMessage, 'error').mockImplementation(() => {})
|
||||||
request.defaults.adapter = respondsWith({ code: 0, data: { ok: true } }, 200, () => {
|
request.defaults.adapter = respondsWith({ code: 0, data: { ok: true } }, 200, () => {
|
||||||
|
|
@ -114,7 +126,7 @@ describe('axios 组织级请求拦截', () => {
|
||||||
commitOrganizationId(2)
|
commitOrganizationId(2)
|
||||||
})
|
})
|
||||||
|
|
||||||
await expect(request.get('/materials')).rejects.toBeInstanceOf(OrganizationContextSupersededError)
|
await expect(request.post('/materials')).rejects.toBeInstanceOf(OrganizationContextSupersededError)
|
||||||
expect(errorSpy).not.toHaveBeenCalled()
|
expect(errorSpy).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -125,7 +137,21 @@ describe('axios 组织级请求拦截', () => {
|
||||||
commitOrganizationId(2)
|
commitOrganizationId(2)
|
||||||
})
|
})
|
||||||
|
|
||||||
await expect(request.get('/materials')).rejects.toBeInstanceOf(OrganizationContextSupersededError)
|
await expect(request.post('/materials')).rejects.toBeInstanceOf(OrganizationContextSupersededError)
|
||||||
|
expect(errorSpy).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('切换公司会中止在途的读请求且不弹提示', async () => {
|
||||||
|
// GET(读请求)挂共享信号:切换提交时 commitOrganizationId 会 abort,请求以取消错误结束,
|
||||||
|
// 拦截器静默处理、不弹提示,页面也不会把作废当失败。
|
||||||
|
commitOrganizationId(1)
|
||||||
|
const errorSpy = vi.spyOn(ElMessage, 'error').mockImplementation(() => {})
|
||||||
|
request.defaults.adapter = respondsWith({ code: 0, data: { ok: true } }, 200, () => {
|
||||||
|
commitOrganizationId(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
const error = await request.get('/materials').catch(err => err)
|
||||||
|
expect(axios.isCancel(error)).toBe(true)
|
||||||
expect(errorSpy).not.toHaveBeenCalled()
|
expect(errorSpy).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user