修复手工开票下载与错误提示
This commit is contained in:
@@ -1,11 +1,13 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import zhCN from '@/i18n/locales/zh-CN'
|
||||
import { ApiError } from '@/services/httpClient'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
import type { AuthHotelResult } from '@/types/auth'
|
||||
import type { ManualInvoiceGenerationResult } from '@/types/manualInvoice'
|
||||
import ReservationManualInvoiceView from '@/views/reservation/ReservationManualInvoiceView.vue'
|
||||
|
||||
@@ -41,7 +43,10 @@ function createResult(): ManualInvoiceGenerationResult {
|
||||
}
|
||||
}
|
||||
|
||||
function mountView(options: { timeZone?: string } = {}) {
|
||||
const nativeCreateObjectURL = URL.createObjectURL
|
||||
const nativeRevokeObjectURL = URL.revokeObjectURL
|
||||
|
||||
function mountView(options: { timeZone?: string; extraHotels?: AuthHotelResult[] } = {}) {
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'zh-CN',
|
||||
@@ -69,6 +74,7 @@ function mountView(options: { timeZone?: string } = {}) {
|
||||
time_zone: options.timeZone ?? 'Asia/Bangkok',
|
||||
default_hotel: true,
|
||||
},
|
||||
...(options.extraHotels ?? []),
|
||||
],
|
||||
permissions: ['RESERVATION_INVOICE_GENERATE'],
|
||||
menus: [],
|
||||
@@ -101,10 +107,37 @@ describe('ReservationManualInvoiceView', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(service.generateManualReservationInvoice).mockReset()
|
||||
sessionStorage.clear()
|
||||
Object.defineProperty(URL, 'createObjectURL', {
|
||||
configurable: true,
|
||||
value: vi.fn(() => 'blob:manual-invoice-pdf'),
|
||||
})
|
||||
Object.defineProperty(URL, 'revokeObjectURL', {
|
||||
configurable: true,
|
||||
value: vi.fn(),
|
||||
})
|
||||
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => undefined)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.unstubAllGlobals()
|
||||
vi.restoreAllMocks()
|
||||
if (nativeCreateObjectURL) {
|
||||
Object.defineProperty(URL, 'createObjectURL', {
|
||||
configurable: true,
|
||||
value: nativeCreateObjectURL,
|
||||
})
|
||||
} else {
|
||||
Reflect.deleteProperty(URL, 'createObjectURL')
|
||||
}
|
||||
if (nativeRevokeObjectURL) {
|
||||
Object.defineProperty(URL, 'revokeObjectURL', {
|
||||
configurable: true,
|
||||
value: nativeRevokeObjectURL,
|
||||
})
|
||||
} else {
|
||||
Reflect.deleteProperty(URL, 'revokeObjectURL')
|
||||
}
|
||||
})
|
||||
|
||||
it('defaults document dates in the selected hotel timezone', () => {
|
||||
@@ -122,6 +155,60 @@ describe('ReservationManualInvoiceView', () => {
|
||||
expect((wrapper.find('[data-testid="document-due-date"]').element as HTMLInputElement).value).toBe('2026-07-25')
|
||||
})
|
||||
|
||||
it('refreshes untouched document dates when switching hotel timezone', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-07-17T12:30:00Z'))
|
||||
const wrapper = mountView({
|
||||
timeZone: 'Asia/Bangkok',
|
||||
extraHotels: [
|
||||
{
|
||||
hotel_id: 'HOTEL-KIRITIMATI',
|
||||
hotel_name: '换日线酒店',
|
||||
time_zone: 'Pacific/Kiritimati',
|
||||
default_hotel: false,
|
||||
},
|
||||
],
|
||||
})
|
||||
const authStore = useAuthStore()
|
||||
|
||||
expect((wrapper.find('[data-testid="document-invoice-date"]').element as HTMLInputElement).value).toBe(
|
||||
'2026-07-17',
|
||||
)
|
||||
|
||||
authStore.setSelectedHotelId('HOTEL-KIRITIMATI')
|
||||
await nextTick()
|
||||
|
||||
expect((wrapper.find('[data-testid="document-invoice-date"]').element as HTMLInputElement).value).toBe(
|
||||
'2026-07-18',
|
||||
)
|
||||
expect((wrapper.find('[data-testid="document-due-date"]').element as HTMLInputElement).value).toBe('2026-07-25')
|
||||
})
|
||||
|
||||
it('does not overwrite manually edited document dates when switching hotel timezone', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-07-17T12:30:00Z'))
|
||||
const wrapper = mountView({
|
||||
timeZone: 'Asia/Bangkok',
|
||||
extraHotels: [
|
||||
{
|
||||
hotel_id: 'HOTEL-KIRITIMATI',
|
||||
hotel_name: '换日线酒店',
|
||||
time_zone: 'Pacific/Kiritimati',
|
||||
default_hotel: false,
|
||||
},
|
||||
],
|
||||
})
|
||||
const authStore = useAuthStore()
|
||||
|
||||
await wrapper.find('[data-testid="document-invoice-date"]').setValue('2026-08-01')
|
||||
authStore.setSelectedHotelId('HOTEL-KIRITIMATI')
|
||||
await nextTick()
|
||||
|
||||
expect((wrapper.find('[data-testid="document-invoice-date"]').element as HTMLInputElement).value).toBe(
|
||||
'2026-08-01',
|
||||
)
|
||||
})
|
||||
|
||||
it('links company and attention seed data while allowing manual overrides before submit', async () => {
|
||||
vi.mocked(service.generateManualReservationInvoice).mockResolvedValue(createResult())
|
||||
const wrapper = mountView()
|
||||
@@ -166,6 +253,32 @@ describe('ReservationManualInvoiceView', () => {
|
||||
expect(wrapper.find('a[href="https://oss.example/invoices/manual-91001.pdf"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('downloads the generated PDF through a browser blob URL', async () => {
|
||||
vi.mocked(service.generateManualReservationInvoice).mockResolvedValue(createResult())
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
blob: () => Promise.resolve(new Blob(['pdf'], { type: 'application/pdf' })),
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
const wrapper = mountView()
|
||||
|
||||
await wrapper.find('[data-testid="recipient-company-code"]').setValue('QBD')
|
||||
await fillMinimumInvoiceForm(wrapper)
|
||||
await wrapper.find('[data-testid="manual-invoice-submit"]').trigger('click')
|
||||
await flushPromises()
|
||||
await wrapper.find('[data-testid="manual-invoice-download-pdf"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith('https://oss.example/invoices/manual-91001.pdf', {
|
||||
credentials: 'omit',
|
||||
})
|
||||
expect(URL.createObjectURL).toHaveBeenCalledWith(expect.any(Blob))
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 0)
|
||||
})
|
||||
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:manual-invoice-pdf')
|
||||
})
|
||||
|
||||
it('submits complete numeric input values instead of parseFloat prefixes', async () => {
|
||||
vi.mocked(service.generateManualReservationInvoice).mockResolvedValue(createResult())
|
||||
const wrapper = mountView()
|
||||
@@ -218,6 +331,31 @@ describe('ReservationManualInvoiceView', () => {
|
||||
expect(wrapper.text()).toContain('PDF 生成超时')
|
||||
})
|
||||
|
||||
it('keeps backend raw details in a folded technical detail block', async () => {
|
||||
vi.mocked(service.generateManualReservationInvoice).mockRejectedValue(
|
||||
new ApiError('Request failed with status 422', 422, {
|
||||
error_code: 'RESERVATION_INVOICE_VALIDATION_FAILED',
|
||||
message: 'invoice_payload.recipient.email is invalid',
|
||||
details: ['invoice_payload.recipient.email: must be a valid email'],
|
||||
}),
|
||||
)
|
||||
const wrapper = mountView()
|
||||
|
||||
await wrapper.find('[data-testid="recipient-company-code"]').setValue('QBD')
|
||||
await fillMinimumInvoiceForm(wrapper)
|
||||
await wrapper.find('[data-testid="manual-invoice-submit"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('[data-testid="manual-invoice-user-messages"]').text()).toContain('Invoice 字段校验失败')
|
||||
expect(wrapper.find('[data-testid="manual-invoice-user-messages"]').text()).not.toContain('invoice_payload')
|
||||
expect(wrapper.find('[data-testid="manual-invoice-technical-details"]').text()).toContain(
|
||||
'invoice_payload.recipient.email is invalid',
|
||||
)
|
||||
expect(wrapper.find('[data-testid="manual-invoice-technical-details"]').text()).toContain(
|
||||
'invoice_payload.recipient.email: must be a valid email',
|
||||
)
|
||||
})
|
||||
|
||||
it('blocks negative extra bed rate before calling the backend', async () => {
|
||||
const wrapper = mountView()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user