From f2652df0ce990f61fae3befe6cc6683633ba405f Mon Sep 17 00:00:00 2001 From: Jacky-Pham Date: Tue, 15 Sep 2026 18:56:34 -0700 Subject: [PATCH] fix: ignore upload completions after changing records --- strr-host-pm-web/app/stores/document.ts | 10 +- .../unit/document-upload-context.spec.ts | 159 ++++++++++++++++++ strr-strata-web/app/stores/document.ts | 10 +- .../unit/document-upload-context.spec.ts | 146 ++++++++++++++++ 4 files changed, 321 insertions(+), 4 deletions(-) create mode 100644 strr-host-pm-web/tests/unit/document-upload-context.spec.ts create mode 100644 strr-strata-web/tests/unit/document-upload-context.spec.ts diff --git a/strr-host-pm-web/app/stores/document.ts b/strr-host-pm-web/app/stores/document.ts index badd85f88..199b304d1 100644 --- a/strr-host-pm-web/app/stores/document.ts +++ b/strr-host-pm-web/app/stores/document.ts @@ -403,6 +403,7 @@ export const useDocumentStore = defineStore('host/document', () => { id: string | number, // string for applications, number for registrations type: 'applications' | 'registrations' ): Promise { + const documentContext = storedDocuments.value try { uiDoc.loading = true @@ -427,6 +428,9 @@ export const useDocumentStore = defineStore('host/document', () => { body: formData }) + // A record load or reset replaces the document list while this request is pending. + if (storedDocuments.value !== documentContext) { return } + // Upload endpoints return the updated record, including the new document's unique file key. const documents = 'registration' in res ? res.registration.documents : res.documents const uploadedDocument = documents.find(doc => @@ -436,8 +440,10 @@ export const useDocumentStore = defineStore('host/document', () => { if (uiDoc.uploadDate) { uploadedDocument.uploadDate = uiDoc.uploadDate } storedDocuments.value.push(uiDoc) } catch (e) { - logFetchError(e, 'Error uploading document') - strrModal.openErrorModal(t('error.docUpload.generic.title'), t('error.docUpload.generic.description'), false) + if (storedDocuments.value === documentContext) { + logFetchError(e, 'Error uploading document') + strrModal.openErrorModal(t('error.docUpload.generic.title'), t('error.docUpload.generic.description'), false) + } throw e } finally { // cleanup loading on ui object diff --git a/strr-host-pm-web/tests/unit/document-upload-context.spec.ts b/strr-host-pm-web/tests/unit/document-upload-context.spec.ts new file mode 100644 index 000000000..9ca06aff5 --- /dev/null +++ b/strr-host-pm-web/tests/unit/document-upload-context.spec.ts @@ -0,0 +1,159 @@ +import { mountSuspended, mockNuxtImport, registerEndpoint } from '@nuxt/test-utils/runtime' +import { enableAutoUnmount, flushPromises } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mockApplication, mockHostRegistration } from '../mocks/mockedData' +import SupportingInfo from '~/components/summary/SupportingInfo.vue' + +const api = vi.fn() +const getApplication = vi.fn() +const getRegistration = vi.fn() +const openErrorModal = vi.fn() + +mockNuxtImport('useRuntimeConfig', original => () => { + const config = original() + return { ...config, public: { ...config.public, strrApiURL: '' } } +}) +registerEndpoint('/applications/APP-101/documents', { method: 'PUT', handler: () => api() }) +registerEndpoint('/registrations/101/documents', { method: 'POST', handler: () => api() }) +mockNuxtImport('useStrrApi', () => () => ({ + getAccountApplication: getApplication, + getAccountRegistrations: getRegistration, + searchRegistrations: vi.fn(), + getApplicationReceipt: vi.fn(), + getRegistrationCert: vi.fn(), + updatePaymentDetails: vi.fn() +})) +mockNuxtImport('useStrrModals', () => () => ({ openErrorModal })) + +enableAutoUnmount(afterEach) + +function document (name: string): ApiDocument { + return { + fileName: `${name}.pdf`, + fileKey: `key-${name}`, + fileType: 'application/pdf', + documentType: DocumentUploadType.UTILITY_BILL + } +} + +function incoming (): UiDocument { + const file = new File(['synthetic'], 'incoming-A.pdf', { type: 'application/pdf' }) + return { + id: 'incoming-A', + file, + name: file.name, + type: DocumentUploadType.UTILITY_BILL, + apiDoc: {} as ApiDocument, + loading: false, + uploadStep: DocumentUploadStep.NOC + } +} + +async function load (kind: 'application' | 'registration', id: number) { + const permit = useHostPermitStore() + if (kind === 'application') { + const value = structuredClone(mockApplication) + value.header.applicationNumber = `APP-${id}` + value.registration.documents = [document(`existing-${id}`)] + getApplication.mockResolvedValueOnce(value) + await permit.loadHostData(`APP-${id}`, false, true) + } else { + const value = structuredClone(mockHostRegistration) + value.id = id + value.registrationNumber = `REG-${id}` + value.documents = [document(`existing-${id}`)] + getRegistration.mockResolvedValueOnce(value) + await permit.loadHostRegistrationData(String(id)) + } +} + +function result (kind: 'application' | 'registration') { + const documents = [document('existing-101'), document('incoming-A')] + return kind === 'application' ? { registration: { documents } } : { documents } +} + +function upload (kind: 'application' | 'registration', doc: UiDocument) { + const store = useDocumentStore() + return kind === 'application' + ? store.addDocumentToApplication(doc, 'APP-101') + : store.addDocumentToRegistration(doc, 101) +} + +let pinia: ReturnType +beforeEach(() => { + pinia = createPinia() + setActivePinia(pinia) + vi.resetAllMocks() +}) + +describe.each(['application', 'registration'] as const)('%s upload document context', (kind) => { + it('adds the returned file while the same record remains loaded', async () => { + await load(kind, 101) + const doc = incoming() + api.mockResolvedValueOnce(result(kind)) + await upload(kind, doc) + expect(doc.apiDoc.fileKey).toBe('key-incoming-A') + expect(useDocumentStore().apiDocuments.map(value => value.fileKey)) + .toEqual(['key-existing-101', 'key-incoming-A']) + expect(doc.loading).toBe(false) + }) + + it('reports a failed upload while the same record remains loaded', async () => { + await load(kind, 101) + const doc = incoming() + api.mockRejectedValue(new Error('Synthetic active upload failure')) + await expect(upload(kind, doc)).rejects.toBeInstanceOf(Error) + expect(useDocumentStore().apiDocuments.map(value => value.fileKey)).toEqual(['key-existing-101']) + expect(openErrorModal).toHaveBeenCalledOnce() + expect(doc.loading).toBe(false) + }) + + it('does not restore documents after the current record has been reset', async () => { + await load(kind, 101) + const pending = Promise.withResolvers>() + api.mockReturnValue(pending.promise) + const doc = incoming() + const request = upload(kind, doc) + await vi.waitFor(() => expect(api).toHaveBeenCalled()) + useHostPermitStore().$reset() + pending.resolve(result(kind)) + await request + expect(useDocumentStore().storedDocuments).toEqual([]) + expect(doc.loading).toBe(false) + }) + + it('keeps a newer record and its rendered documents after an older upload succeeds', async () => { + await load(kind, 101) + const pending = Promise.withResolvers>() + api.mockReturnValue(pending.promise) + const doc = incoming() + const request = upload(kind, doc) + await vi.waitFor(() => expect(api).toHaveBeenCalled()) + await load(kind, 202) + const wrapper = await mountSuspended(SupportingInfo, { props: { isDashboard: true }, global: { plugins: [pinia] } }) + expect(wrapper.text()).toContain('existing-202.pdf') + pending.resolve(result(kind)) + await request + await flushPromises() + expect(useDocumentStore().apiDocuments.map(value => value.fileKey)).toEqual(['key-existing-202']) + expect(wrapper.text()).not.toContain('incoming-A.pdf') + expect(doc.loading).toBe(false) + }) + + it('keeps a newer record free of an older upload error dialog', async () => { + await load(kind, 101) + const pending = Promise.withResolvers>() + api.mockReturnValue(pending.promise) + const doc = incoming() + const request = upload(kind, doc).catch(error => error) + await vi.waitFor(() => expect(api).toHaveBeenCalled()) + await load(kind, 202) + const failure = new Error('Synthetic old upload failure') + pending.reject(failure) + expect(await request).toBeInstanceOf(Error) + expect(useDocumentStore().apiDocuments.map(value => value.fileKey)).toEqual(['key-existing-202']) + expect(openErrorModal).not.toHaveBeenCalled() + expect(doc.loading).toBe(false) + }) +}) diff --git a/strr-strata-web/app/stores/document.ts b/strr-strata-web/app/stores/document.ts index 2f5de5007..3db2185b0 100644 --- a/strr-strata-web/app/stores/document.ts +++ b/strr-strata-web/app/stores/document.ts @@ -57,6 +57,7 @@ export const useDocumentStore = defineStore('strata/document', () => { * @returns {Promise} A promise that resolves when the document has been added or rejects if an error occurs. */ async function addDocumentToApplication (uiDoc: UiDocument, applicationNumber: string): Promise { + const documentContext = storedDocuments.value try { uiDoc.loading = true @@ -77,12 +78,17 @@ export const useDocumentStore = defineStore('strata/document', () => { body: formData }) + // A record load or reset replaces the document list while this request is pending. + if (storedDocuments.value !== documentContext) { return } + uiDoc.apiDoc = res.registration.documents!.find(doc => !storedDocuments.value.some(stored => stored.apiDoc.fileKey === doc.fileKey))! storedDocuments.value.push(uiDoc) } catch (e) { - logFetchError(e, 'Error uploading document') - strrModal.openErrorModal(t('error.docUpload.generic.title'), t('error.docUpload.generic.description'), false) + if (storedDocuments.value === documentContext) { + logFetchError(e, 'Error uploading document') + strrModal.openErrorModal(t('error.docUpload.generic.title'), t('error.docUpload.generic.description'), false) + } throw e } finally { // cleanup loading on ui object diff --git a/strr-strata-web/tests/unit/document-upload-context.spec.ts b/strr-strata-web/tests/unit/document-upload-context.spec.ts new file mode 100644 index 000000000..8e5c4732e --- /dev/null +++ b/strr-strata-web/tests/unit/document-upload-context.spec.ts @@ -0,0 +1,146 @@ +import { mountSuspended, mockNuxtImport, registerEndpoint } from '@nuxt/test-utils/runtime' +import { enableAutoUnmount, flushPromises } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mockPermitDetailsData } from '../mocks/mockedData' +import DocumentList from '~/components/document/list/index.vue' + +const api = vi.fn() +const getApplication = vi.fn() +const getRegistration = vi.fn() +const openErrorModal = vi.fn() + +mockNuxtImport('useRuntimeConfig', original => () => { + const config = original() + return { ...config, public: { ...config.public, strrApiURL: '' } } +}) +registerEndpoint('/applications/APP-101/documents', { method: 'PUT', handler: () => api() }) +mockNuxtImport('useStrrApi', () => () => ({ + getAccountApplication: getApplication, + getAccountRegistrations: getRegistration, + searchRegistrations: vi.fn(), + getApplicationReceipt: vi.fn(), + getRegistrationCert: vi.fn(), + updatePaymentDetails: vi.fn() +})) +mockNuxtImport('useStrrModals', () => () => ({ openErrorModal })) + +enableAutoUnmount(afterEach) + +function document (name: string): ApiDocument { + return { + fileName: `${name}.pdf`, + fileKey: `key-${name}`, + fileType: 'application/pdf', + documentType: DocumentUploadType.STRATA_HOTEL_DOCUMENTATION + } +} + +function incoming (): UiDocument { + const file = new File(['synthetic'], 'incoming-A.pdf', { type: 'application/pdf' }) + return { + id: 'incoming-A', + file, + name: file.name, + type: DocumentUploadType.STRATA_HOTEL_DOCUMENTATION, + apiDoc: {} as ApiDocument, + loading: false, + uploadStep: DocumentUploadStep.NOC + } +} + +async function load (id: number) { + const registration = structuredClone(mockPermitDetailsData) + registration.documents = [document(`existing-${id}`)] + getApplication.mockResolvedValueOnce({ + header: { applicationNumber: `APP-${id}`, status: ApplicationStatus.NOC_PENDING }, + registration + }) + await useStrrStrataStore().loadStrata(`APP-${id}`) +} + +function result () { + return { registration: { documents: [document('existing-101'), document('incoming-A')] } } +} + +function upload (doc: UiDocument) { + return useDocumentStore().addDocumentToApplication(doc, 'APP-101') +} + +let pinia: ReturnType +beforeEach(() => { + pinia = createPinia() + setActivePinia(pinia) + vi.resetAllMocks() +}) + +describe('Strata upload document context', () => { + it('adds the returned file while the same record remains loaded', async () => { + await load(101) + const doc = incoming() + api.mockResolvedValueOnce(result()) + await upload(doc) + expect(doc.apiDoc.fileKey).toBe('key-incoming-A') + expect(useDocumentStore().apiDocuments.map(value => value.fileKey)) + .toEqual(['key-existing-101', 'key-incoming-A']) + expect(doc.loading).toBe(false) + }) + + it('reports a failed upload while the same record remains loaded', async () => { + await load(101) + const doc = incoming() + api.mockRejectedValue(new Error('Synthetic active upload failure')) + await expect(upload(doc)).rejects.toBeInstanceOf(Error) + expect(useDocumentStore().apiDocuments.map(value => value.fileKey)).toEqual(['key-existing-101']) + expect(openErrorModal).toHaveBeenCalledOnce() + expect(doc.loading).toBe(false) + }) + + it('does not restore documents after the current record has been reset', async () => { + await load(101) + const pending = Promise.withResolvers>() + api.mockReturnValue(pending.promise) + const doc = incoming() + const request = upload(doc) + await vi.waitFor(() => expect(api).toHaveBeenCalled()) + useStrrStrataStore().$reset() + pending.resolve(result()) + await request + expect(useDocumentStore().storedDocuments).toEqual([]) + expect(doc.loading).toBe(false) + }) + + it('keeps a newer record and its rendered documents after an older upload succeeds', async () => { + await load(101) + const pending = Promise.withResolvers>() + api.mockReturnValue(pending.promise) + const doc = incoming() + const request = upload(doc) + await vi.waitFor(() => expect(api).toHaveBeenCalled()) + await load(202) + const wrapper = await mountSuspended(DocumentList, { global: { plugins: [pinia] } }) + expect(wrapper.text()).toContain('existing-202.pdf') + pending.resolve(result()) + await request + await flushPromises() + expect(useDocumentStore().apiDocuments.map(value => value.fileKey)).toEqual(['key-existing-202']) + expect(wrapper.text()).not.toContain('incoming-A.pdf') + expect(doc.loading).toBe(false) + }) + + it('keeps a newer record free of an older upload error dialog', async () => { + await load(101) + const pending = Promise.withResolvers>() + api.mockReturnValue(pending.promise) + const doc = incoming() + const request = upload(doc).catch(error => error) + await vi.waitFor(() => expect(api).toHaveBeenCalled()) + await load(202) + const failure = new Error('Synthetic old upload failure') + pending.reject(failure) + expect(await request).toBeInstanceOf(Error) + expect(useDocumentStore().apiDocuments.map(value => value.fileKey)).toEqual(['key-existing-202']) + expect(openErrorModal).not.toHaveBeenCalled() + expect(doc.loading).toBe(false) + }) +})